From 548c8a7e06402d5ed8e8e3c965263536fc997bae Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Fri, 22 May 2026 09:45:43 +0100 Subject: [PATCH] impl migration to pdfium for crop and scale --- .../SPDF/controller/api/CropController.java | 219 ++++++------ .../controller/api/ScalePagesController.java | 206 ++++++------ .../controller/api/CropControllerTest.java | 306 +++++------------ .../api/ScalePagesControllerTest.java | 311 ++++++++---------- 4 files changed, 404 insertions(+), 638 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index f5a0cdcba9..8186ce2600 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -1,21 +1,15 @@ package stirling.software.SPDF.controller.api; import java.awt.image.BufferedImage; +import java.io.File; import java.io.IOException; import java.util.List; -import org.apache.pdfbox.multipdf.LayerUtility; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode; -import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; -import org.apache.pdfbox.rendering.PDFRenderer; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; @@ -27,13 +21,19 @@ import stirling.software.SPDF.model.api.general.CropPdfForm; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; -import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfImageConverter; +import stirling.software.jpdfium.PdfPage; +import stirling.software.jpdfium.doc.PdfMarginAdjuster; +import stirling.software.jpdfium.model.PageSize; +import stirling.software.jpdfium.model.Rect; +import stirling.software.jpdfium.transform.PdfPageBoxes; @GeneralApi @RequiredArgsConstructor @@ -42,18 +42,15 @@ public class CropController { private static final int DEFAULT_RENDER_DPI = 150; private static final int WHITE_THRESHOLD = 250; - private static final String TEMP_INPUT_PREFIX = "crop_input"; - private static final String TEMP_OUTPUT_PREFIX = "crop_output"; private static final String PDF_EXTENSION = ".pdf"; - private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; + private final EndpointConfiguration endpointConfiguration; private static int[] detectContentBounds(BufferedImage image) { int width = image.getWidth(); int height = image.getHeight(); - // Early exit if image is too small if (width < 1 || height < 1) { return new int[] {0, 0, width - 1, height - 1}; } @@ -109,8 +106,7 @@ public class CropController { } } - // Return bounds in format: [left, bottom, right, top] - // Note: Image coordinates are top-down, PDF coordinates are bottom-up + // Image coordinates top-down, PDF coordinates bottom-up return new int[] {left, height - bottom - 1, right, height - top - 1}; } @@ -121,8 +117,6 @@ public class CropController { return r >= threshold && g >= threshold && b >= threshold; } - private final EndpointConfiguration endpointConfiguration; - private boolean isGhostscriptEnabled() { return endpointConfiguration.isGroupEnabled("Ghostscript"); } @@ -152,145 +146,124 @@ public class CropController { if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) { return cropWithGhostscript(request); - } else { - return cropWithPDFBox(request); } + return cropWithJpdfium(request); } - private ResponseEntity cropWithAutomaticDetection(@ModelAttribute CropPdfForm request) + private ResponseEntity cropWithAutomaticDetection(CropPdfForm request) throws IOException { - try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { + MultipartFile fileInput = request.getFileInput(); + File inputFile = tempFileManager.convertMultipartFileToFile(fileInput); + try { + try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) { + int pageCount = doc.pageCount(); + for (int i = 0; i < pageCount; i++) { + BufferedImage image = PdfImageConverter.pageToImage(doc, i, DEFAULT_RENDER_DPI); - try (PDDocument newDocument = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { - PDFRenderer renderer = new PDFRenderer(sourceDocument); - renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage - LayerUtility layerUtility = new LayerUtility(newDocument); + PageSize size; + try (PdfPage page = doc.page(i)) { + size = page.size(); + } - for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { - PDPage sourcePage = sourceDocument.getPage(i); - PDRectangle mediaBox = sourcePage.getMediaBox(); - - BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI); int[] bounds = detectContentBounds(image); - - float scaleX = mediaBox.getWidth() / image.getWidth(); - float scaleY = mediaBox.getHeight() / image.getHeight(); + float scaleX = size.width() / image.getWidth(); + float scaleY = size.height() / image.getHeight(); CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY); - - PDPage newPage = new PDPage(mediaBox); - newDocument.addPage(newPage); - try (PDPageContentStream contentStream = - new PDPageContentStream( - newDocument, newPage, AppendMode.OVERWRITE, true, true)) { - PDFormXObject formXObject = - layerUtility.importPageAsForm(sourceDocument, i); - contentStream.saveGraphicsState(); - contentStream.addRect( - cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height); - contentStream.clip(); - contentStream.drawForm(formXObject); - contentStream.restoreGraphicsState(); - } - - newPage.setMediaBox( - new PDRectangle( - cropBounds.x, - cropBounds.y, - cropBounds.width, - cropBounds.height)); + applyCropMargins(doc, i, size, cropBounds); } - return WebResponseUtils.pdfDocToWebResponse( - newDocument, + TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION); + try { + doc.save(outputTempFile.getPath()); + } catch (Exception e) { + outputTempFile.close(); + throw e; + } + return WebResponseUtils.pdfFileToWebResponse( + outputTempFile, GeneralUtils.generateFilename( - request.getFileInput().getOriginalFilename(), "_cropped.pdf"), - tempFileManager); + fileInput.getOriginalFilename(), "_cropped.pdf")); } + } finally { + tempFileManager.deleteTempFile(inputFile); } } - private ResponseEntity cropWithPDFBox(@ModelAttribute CropPdfForm request) - throws IOException { - try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { - - try (PDDocument newDocument = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { - int totalPages = sourceDocument.getNumberOfPages(); - LayerUtility layerUtility = new LayerUtility(newDocument); - - for (int i = 0; i < totalPages; i++) { - PDPage sourcePage = sourceDocument.getPage(i); - - // Create a new page with the size of the source page - PDPage newPage = new PDPage(sourcePage.getMediaBox()); - newDocument.addPage(newPage); - try (PDPageContentStream contentStream = - new PDPageContentStream( - newDocument, newPage, AppendMode.OVERWRITE, true, true)) { - // Import the source page as a form XObject - PDFormXObject formXObject = - layerUtility.importPageAsForm(sourceDocument, i); - - contentStream.saveGraphicsState(); - - // Define the crop area - contentStream.addRect( + private ResponseEntity cropWithJpdfium(CropPdfForm request) throws IOException { + MultipartFile fileInput = request.getFileInput(); + File inputFile = tempFileManager.convertMultipartFileToFile(fileInput); + try { + try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) { + int pageCount = doc.pageCount(); + CropBounds bounds = + new CropBounds( request.getX(), request.getY(), request.getWidth(), request.getHeight()); - contentStream.clip(); - - // Draw the entire formXObject - contentStream.drawForm(formXObject); - - contentStream.restoreGraphicsState(); + for (int i = 0; i < pageCount; i++) { + PageSize size; + try (PdfPage page = doc.page(i)) { + size = page.size(); } - - // Now, set the new page's media box to the cropped size - newPage.setMediaBox( - new PDRectangle( - request.getX(), - request.getY(), - request.getWidth(), - request.getHeight())); + applyCropMargins(doc, i, size, bounds); } - return WebResponseUtils.pdfDocToWebResponse( - newDocument, + TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION); + try { + doc.save(outputTempFile.getPath()); + } catch (Exception e) { + outputTempFile.close(); + throw e; + } + return WebResponseUtils.pdfFileToWebResponse( + outputTempFile, GeneralUtils.generateFilename( - request.getFileInput().getOriginalFilename(), "_cropped.pdf"), - tempFileManager); + fileInput.getOriginalFilename(), "_cropped.pdf")); } + } finally { + tempFileManager.deleteTempFile(inputFile); } } - private ResponseEntity cropWithGhostscript(@ModelAttribute CropPdfForm request) - throws IOException { + // PdfMarginAdjuster.addMargins(doc, page, left, bottom, right, top): translates + // content by (left, bottom), grows page by left+right and bottom+top. + // To crop to (x, y, w, h) shift content to (-x, -y) and end at (w, h). + private static void applyCropMargins( + PdfDocument doc, int pageIndex, PageSize size, CropBounds bounds) { + float left = -bounds.x(); + float bottom = -bounds.y(); + float right = bounds.width() - size.width() + bounds.x(); + float top = bounds.height() - size.height() + bounds.y(); + PdfMarginAdjuster.addMargins(doc, pageIndex, left, bottom, right, top); + } + + private ResponseEntity cropWithGhostscript(CropPdfForm request) throws IOException { + MultipartFile fileInput = request.getFileInput(); + File inputFile = tempFileManager.convertMultipartFileToFile(fileInput); TempFile tempInputFile = null; TempFile tempOutputFile = null; + try { + tempInputFile = new TempFile(tempFileManager, PDF_EXTENSION); + tempOutputFile = new TempFile(tempFileManager, PDF_EXTENSION); - try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { - for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { - PDPage page = sourceDocument.getPage(i); - PDRectangle cropBox = - new PDRectangle( + try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) { + Rect cropRect = + Rect.of( request.getX(), request.getY(), request.getWidth(), request.getHeight()); - page.setCropBox(cropBox); + int pageCount = doc.pageCount(); + for (int i = 0; i < pageCount; i++) { + try (PdfPage page = doc.page(i)) { + PdfPageBoxes.setCropBox(page.rawHandle(), cropRect); + } + } + doc.save(tempInputFile.getPath()); } - tempInputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION); - tempOutputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION); - - // Save the source document with crop boxes - sourceDocument.save(tempInputFile.getFile()); - - // Execute Ghostscript to process the crop boxes ProcessExecutor processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT); List command = @@ -301,16 +274,13 @@ public class CropController { "-o", tempOutputFile.getAbsolutePath(), tempInputFile.getAbsolutePath()); - processExecutor.runCommandWithOutputHandling(command); TempFile out = tempOutputFile; - tempOutputFile = null; // ownership transferred to response Resource + tempOutputFile = null; return WebResponseUtils.pdfFileToWebResponse( out, - GeneralUtils.generateFilename( - request.getFileInput().getOriginalFilename(), "_cropped.pdf")); - + GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_cropped.pdf")); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e); @@ -321,6 +291,7 @@ public class CropController { if (tempOutputFile != null) { tempOutputFile.close(); } + tempFileManager.deleteTempFile(inputFile); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java index 2965870bb1..cef1ffb6b1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java @@ -1,16 +1,10 @@ package stirling.software.SPDF.controller.api; +import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; -import org.apache.pdfbox.multipdf.LayerUtility; -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.form.PDFormXObject; -import org.apache.pdfbox.util.Matrix; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -26,91 +20,88 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; -import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfPage; +import stirling.software.jpdfium.doc.PdfPageScaler; +import stirling.software.jpdfium.doc.PdfPosterizer.PaperSize; +import stirling.software.jpdfium.model.PageSize; +import stirling.software.jpdfium.model.Rect; +import stirling.software.jpdfium.transform.PdfPageBoxes; @GeneralApi @Slf4j @RequiredArgsConstructor public class ScalePagesController { - private final CustomPDFDocumentFactory pdfDocumentFactory; + // PDFBox PDRectangle dimensions (width, height) in points, inlined so the + // controller has no remaining PDFBox dependency. + private static final float A0_W = 2383.937f; + private static final float A0_H = 3370.3937f; + private static final float A1_W = 1683.7795f; + private static final float A1_H = 2383.937f; + private static final float A2_W = 1190.5511f; + private static final float A2_H = 1683.7795f; + private static final float A3_W = 841.8898f; + private static final float A3_H = 1190.5511f; + private static final float A4_W = 595.27563f; + private static final float A4_H = 841.8898f; + private static final float A5_W = 419.52756f; + private static final float A5_H = 595.27563f; + private static final float A6_W = 297.63782f; + private static final float A6_H = 419.52756f; + private static final float LETTER_W = 612f; + private static final float LETTER_H = 792f; + private static final float LEGAL_W = 612f; + private static final float LEGAL_H = 1008f; + private final TempFileManager tempFileManager; - private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) { - if ("KEEP".equals(targetPDRectangle)) { - if (sourceDocument.getNumberOfPages() == 0) { + private static PageSize getTargetSize(String name, PdfDocument sourceDocument) { + if ("KEEP".equals(name)) { + if (sourceDocument.pageCount() == 0) { throw ExceptionUtils.createInvalidPageSizeException("KEEP"); } - - PDPage sourcePage = sourceDocument.getPage(0); - PDRectangle sourceSize = sourcePage.getMediaBox(); - - if (sourceSize == null) { - throw ExceptionUtils.createInvalidPageSizeException("KEEP"); + try (PdfPage first = sourceDocument.page(0)) { + return first.size(); } - - return sourceSize; } - - Map sizeMap = getSizeMap(); - - if (sizeMap.containsKey(targetPDRectangle)) { - return sizeMap.get(targetPDRectangle); + Map sizeMap = getSizeMap(); + PageSize size = sizeMap.get(name); + if (size == null) { + throw ExceptionUtils.createInvalidPageSizeException(name); } - - throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle); + return size; } - private static Map getSizeMap() { - Map sizeMap = new HashMap<>(); + private static Map getSizeMap() { + Map sizeMap = new HashMap<>(); - // Portrait sizes (A0-A6) - sizeMap.put("A0", PDRectangle.A0); - sizeMap.put("A1", PDRectangle.A1); - sizeMap.put("A2", PDRectangle.A2); - sizeMap.put("A3", PDRectangle.A3); - sizeMap.put("A4", PDRectangle.A4); - sizeMap.put("A5", PDRectangle.A5); - sizeMap.put("A6", PDRectangle.A6); + sizeMap.put("A0", new PageSize(A0_W, A0_H)); + sizeMap.put("A1", new PageSize(A1_W, A1_H)); + sizeMap.put("A2", new PageSize(A2_W, A2_H)); + sizeMap.put("A3", new PageSize(A3_W, A3_H)); + sizeMap.put("A4", new PageSize(A4_W, A4_H)); + sizeMap.put("A5", new PageSize(A5_W, A5_H)); + sizeMap.put("A6", new PageSize(A6_W, A6_H)); - // Landscape sizes (A0-A6) - sizeMap.put( - "A0_LANDSCAPE", - new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth())); - sizeMap.put( - "A1_LANDSCAPE", - new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth())); - sizeMap.put( - "A2_LANDSCAPE", - new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth())); - sizeMap.put( - "A3_LANDSCAPE", - new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth())); - sizeMap.put( - "A4_LANDSCAPE", - new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth())); - sizeMap.put( - "A5_LANDSCAPE", - new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth())); - sizeMap.put( - "A6_LANDSCAPE", - new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth())); + sizeMap.put("A0_LANDSCAPE", new PageSize(A0_H, A0_W)); + sizeMap.put("A1_LANDSCAPE", new PageSize(A1_H, A1_W)); + sizeMap.put("A2_LANDSCAPE", new PageSize(A2_H, A2_W)); + sizeMap.put("A3_LANDSCAPE", new PageSize(A3_H, A3_W)); + sizeMap.put("A4_LANDSCAPE", new PageSize(A4_H, A4_W)); + sizeMap.put("A5_LANDSCAPE", new PageSize(A5_H, A5_W)); + sizeMap.put("A6_LANDSCAPE", new PageSize(A6_H, A6_W)); - // Portrait US sizes - sizeMap.put("LETTER", PDRectangle.LETTER); - sizeMap.put("LEGAL", PDRectangle.LEGAL); + sizeMap.put("LETTER", new PageSize(LETTER_W, LETTER_H)); + sizeMap.put("LEGAL", new PageSize(LEGAL_W, LEGAL_H)); - // Landscape US sizes - sizeMap.put( - "LETTER_LANDSCAPE", - new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth())); - sizeMap.put( - "LEGAL_LANDSCAPE", - new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth())); + sizeMap.put("LETTER_LANDSCAPE", new PageSize(LETTER_H, LETTER_W)); + sizeMap.put("LEGAL_LANDSCAPE", new PageSize(LEGAL_H, LEGAL_W)); return sizeMap; } @@ -130,53 +121,46 @@ public class ScalePagesController { String targetPDRectangle = request.getPageSize(); float scaleFactor = request.getScaleFactor(); - try (PDDocument sourceDocument = pdfDocumentFactory.load(file); - PDDocument outputDocument = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { + File inputFile = tempFileManager.convertMultipartFileToFile(file); + try { + try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) { + PageSize targetSize = getTargetSize(targetPDRectangle, doc); + float targetW = targetSize.width(); + float targetH = targetSize.height(); - PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument); + // Build a "virtual" paper so PdfPageScaler.scale produces base*scaleFactor + // scaling, then restore the real MediaBox/CropBox to the requested target + // size with the content centered. + float virtualW = targetW * scaleFactor; + float virtualH = targetH * scaleFactor; + PaperSize virtualPaper = new PaperSize(virtualW, virtualH, "virtual"); - // Create LayerUtility once outside the loop for better performance - LayerUtility layerUtility = new LayerUtility(outputDocument); + int pageCount = doc.pageCount(); + for (int i = 0; i < pageCount; i++) { + PdfPageScaler.scale(doc, i, virtualPaper, PdfPageScaler.FitMode.FIT_PAGE); - int totalPages = sourceDocument.getNumberOfPages(); - for (int i = 0; i < totalPages; i++) { - PDPage sourcePage = sourceDocument.getPage(i); - PDRectangle sourceSize = sourcePage.getMediaBox(); - - float scaleWidth = targetSize.getWidth() / sourceSize.getWidth(); - float scaleHeight = targetSize.getHeight() / sourceSize.getHeight(); - float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor; - - PDPage newPage = new PDPage(targetSize); - outputDocument.addPage(newPage); - - try (PDPageContentStream contentStream = - new PDPageContentStream( - outputDocument, - newPage, - PDPageContentStream.AppendMode.APPEND, - true, - true)) { - - float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2; - float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2; - - contentStream.saveGraphicsState(); - contentStream.transform(Matrix.getTranslateInstance(x, y)); - contentStream.transform(Matrix.getScaleInstance(scale, scale)); - - PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i); - contentStream.drawForm(form); - - contentStream.restoreGraphicsState(); + float offsetX = (virtualW - targetW) / 2f; + float offsetY = (virtualH - targetH) / 2f; + Rect mediaBox = Rect.of(offsetX, offsetY, targetW, targetH); + try (PdfPage page = doc.page(i)) { + PdfPageBoxes.setMediaBox(page.rawHandle(), mediaBox); + PdfPageBoxes.setCropBox(page.rawHandle(), mediaBox); + } } - } - return WebResponseUtils.pdfDocToWebResponse( - outputDocument, - GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"), - tempFileManager); + TempFile outputTempFile = new TempFile(tempFileManager, ".pdf"); + try { + doc.save(outputTempFile.getPath()); + } catch (Exception e) { + outputTempFile.close(); + throw e; + } + return WebResponseUtils.pdfFileToWebResponse( + outputTempFile, + GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf")); + } + } finally { + tempFileManager.deleteTempFile(inputFile); } } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java index 9ef8669295..f9a3d8875e 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.controller.api; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -27,53 +28,50 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.model.api.general.CropPdfForm; -import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) @DisplayName("CropController Tests") class CropControllerTest { - private static ResponseEntity streamingOk(byte[] bytes) { - return ResponseEntity.ok(new ByteArrayResource(bytes)); - } - private static byte[] drainBody(ResponseEntity response) throws java.io.IOException { + private static byte[] drainBody(ResponseEntity response) throws IOException { java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - try (java.io.InputStream __in = response.getBody().getInputStream()) { - __in.transferTo(baos); + try (java.io.InputStream in = response.getBody().getInputStream()) { + in.transferTo(baos); } return baos.toByteArray(); } @TempDir Path tempDir; - @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; @InjectMocks private CropController cropController; private TestPdfFactory pdfFactory; @BeforeEach void setUp() throws Exception { lenient() - .when(tempFileManager.createManagedTempFile(anyString())) + .when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class))) .thenAnswer( inv -> { - File f = - Files.createTempFile("test", inv.getArgument(0)) - .toFile(); - TempFile tf = mock(TempFile.class); - lenient().when(tf.getFile()).thenReturn(f); - lenient().when(tf.getPath()).thenReturn(f.toPath()); - return tf; + MultipartFile mf = inv.getArgument(0); + File f = Files.createTempFile("crop-in", ".pdf").toFile(); + Files.write(f.toPath(), mf.getBytes()); + return f; }); + lenient() + .when(tempFileManager.createTempFile(anyString())) + .thenAnswer(inv -> Files.createTempFile("crop-out", inv.getArgument(0)).toFile()); + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); pdfFactory = new TestPdfFactory(); } @@ -112,8 +110,10 @@ class CropControllerTest { private static final PDType1Font HELVETICA = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + // PDFium TransFormWithClip rejects pages without content streams; always + // add at least a sentinel character so the page has a /Contents entry. MockMultipartFile createStandardPdf(String filename) throws IOException { - return createPdf(filename, PDRectangle.LETTER, null); + return createPdf(filename, PDRectangle.LETTER, "X"); } MockMultipartFile createPdfWithContent(String filename, String content) throws IOException { @@ -121,30 +121,26 @@ class CropControllerTest { } MockMultipartFile createPdfWithSize(String filename, PDRectangle size) throws IOException { - return createPdf(filename, size, null); + return createPdf(filename, size, "X"); } MockMultipartFile createPdf(String filename, PDRectangle pageSize, String content) throws IOException { Path testPdfPath = tempDir.resolve(filename); - try (PDDocument doc = new PDDocument()) { PDPage page = new PDPage(pageSize); doc.addPage(page); - if (content != null && !content.isEmpty()) { - try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) { - contentStream.beginText(); - contentStream.setFont(HELVETICA, 12); - contentStream.newLineAtOffset(50, pageSize.getHeight() - 50); - contentStream.showText(content); - contentStream.endText(); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(HELVETICA, 12); + cs.newLineAtOffset(50, pageSize.getHeight() - 50); + cs.showText(content); + cs.endText(); } } - doc.save(testPdfPath.toFile()); } - return new MockMultipartFile( "fileInput", filename, @@ -156,26 +152,22 @@ class CropControllerTest { throws IOException { Path testPdfPath = tempDir.resolve(filename); PDRectangle pageSize = PDRectangle.LETTER; - try (PDDocument doc = new PDDocument()) { PDPage page = new PDPage(pageSize); doc.addPage(page); - if (content != null && !content.isEmpty()) { - try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) { - contentStream.beginText(); - contentStream.setFont(HELVETICA, 12); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(HELVETICA, 12); float x = pageSize.getWidth() / 2 - 50; float y = pageSize.getHeight() / 2; - contentStream.newLineAtOffset(x, y); - contentStream.showText(content); - contentStream.endText(); + cs.newLineAtOffset(x, y); + cs.showText(content); + cs.endText(); } } - doc.save(testPdfPath.toFile()); } - return new MockMultipartFile( "fileInput", filename, @@ -185,43 +177,34 @@ class CropControllerTest { } @Nested - @DisplayName("Manual Crop with PDFBox") - class ManualCropPDFBoxTests { + @DisplayName("Manual Crop with JPDFium") + @Tag("integration") + class ManualCropTests { @Test - @DisplayName( - "Should successfully crop PDF using PDFBox when removeDataOutsideCrop is false") - void shouldCropPdfSuccessfullyWithPDFBox() throws IOException { + @DisplayName("Should produce PDF with cropped MediaBox dimensions") + void shouldCropPdfWithExpectedDimensions() throws IOException { MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf"); + float w = 512f; + float h = 692f; CropPdfForm request = new CropRequestBuilder() .withFile(testFile) - .withCoordinates(50f, 50f, 512f, 692f) + .withCoordinates(50f, 50f, w, h) .withRemoveDataOutsideCrop(false) .withAutoCrop(false) .build(); - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - ResponseEntity response = cropController.cropPdf(request); - assertThat(response) - .isNotNull() - .extracting(ResponseEntity::getStatusCode, ResponseEntity::getBody) - .satisfies( - tuple -> { - assertThat(tuple.get(0)).isEqualTo(HttpStatus.OK); - assertThat(tuple.get(1)).isNotNull(); - }); - - verify(pdfDocumentFactory).load(request); - verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(mockDocument); - verify(mockDocument, times(1)).close(); - verify(newDocument, times(1)).close(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + byte[] out = drainBody(response); + try (PDDocument result = Loader.loadPDF(out)) { + assertThat(result.getNumberOfPages()).isEqualTo(1); + PDRectangle mediaBox = result.getPage(0).getMediaBox(); + assertThat(mediaBox.getWidth()).isCloseTo(w, within(0.1f)); + assertThat(mediaBox.getHeight()).isCloseTo(h, within(0.1f)); + } } @ParameterizedTest @@ -238,21 +221,15 @@ class CropControllerTest { .withAutoCrop(false) .build(); - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - ResponseEntity response = cropController.cropPdf(request); - assertThat(response).isNotNull(); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isNotNull(); - - verify(pdfDocumentFactory).load(request); - verify(mockDocument, times(1)).close(); - verify(newDocument, times(1)).close(); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + assertThat(result.getNumberOfPages()).isEqualTo(1); + PDRectangle mediaBox = result.getPage(0).getMediaBox(); + assertThat(mediaBox.getWidth()).isCloseTo(width, within(0.5f)); + assertThat(mediaBox.getHeight()).isCloseTo(height, within(0.5f)); + } } } @@ -261,69 +238,44 @@ class CropControllerTest { @Tag("integration") class AutoCropTests { - private TestPdfFactory autoCropPdfFactory; - - @BeforeEach - void setUp() throws Exception { - autoCropPdfFactory = new TestPdfFactory(); - } - @Test @DisplayName("Should auto-crop PDF with content successfully") void shouldAutoCropPdfSuccessfully() throws IOException { MockMultipartFile testFile = - autoCropPdfFactory.createPdfWithCenteredContent( + pdfFactory.createPdfWithCenteredContent( "test_autocrop.pdf", "Test Content for Auto Crop"); CropPdfForm request = new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build(); - // Mock the pdfDocumentFactory to load real PDFs - try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes()); - PDDocument newDoc = new PDDocument()) { - when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) - .thenReturn(newDoc); + ResponseEntity response = cropController.cropPdf(request); - ResponseEntity response = cropController.cropPdf(request); - - assertThat(response).isNotNull(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(drainBody(response)).isNotEmpty(); - - try (PDDocument result = Loader.loadPDF(drainBody(response))) { - assertThat(result.getNumberOfPages()).isEqualTo(1); - - PDPage page = result.getPage(0); - assertThat(page).isNotNull(); - assertThat(page.getMediaBox()).isNotNull(); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + byte[] out = drainBody(response); + assertThat(out).isNotEmpty(); + try (PDDocument result = Loader.loadPDF(out)) { + assertThat(result.getNumberOfPages()).isEqualTo(1); + PDPage page = result.getPage(0); + assertThat(page.getMediaBox()).isNotNull(); + // Cropped result should be smaller than original LETTER. + assertThat(page.getMediaBox().getWidth()).isLessThan(PDRectangle.LETTER.getWidth()); + assertThat(page.getMediaBox().getHeight()) + .isLessThan(PDRectangle.LETTER.getHeight()); } } @Test @DisplayName("Should handle PDF with minimal content") void shouldHandleMinimalContentPdf() throws IOException { - MockMultipartFile testFile = - autoCropPdfFactory.createPdfWithContent("minimal.pdf", "X"); + MockMultipartFile testFile = pdfFactory.createPdfWithContent("minimal.pdf", "X"); CropPdfForm request = new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build(); - // Mock the pdfDocumentFactory to load real PDFs - try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes()); - PDDocument newDoc = new PDDocument()) { - when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) - .thenReturn(newDoc); + ResponseEntity response = cropController.cropPdf(request); - ResponseEntity response = cropController.cropPdf(request); - - assertThat(response).isNotNull(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - Assertions.assertNotNull(response.getBody()); - try (PDDocument result = Loader.loadPDF(drainBody(response))) { - assertThat(result.getNumberOfPages()).isEqualTo(1); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + assertThat(result.getNumberOfPages()).isEqualTo(1); } } } @@ -384,9 +336,7 @@ class CropControllerTest { @DisplayName("Should detect full image bounds for all white image") void shouldDetectFullBoundsForWhiteImage() throws Exception { BufferedImage whiteImage = createWhiteImage(100, 100); - int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, whiteImage); - assertThat(bounds).containsExactly(0, 0, 99, 99); } @@ -395,9 +345,7 @@ class CropControllerTest { void shouldDetectBlackRectangleBounds() throws Exception { BufferedImage image = createWhiteImage(100, 100); drawBlackRectangle(image, 25, 25, 75, 75); - int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image); - assertThat(bounds).containsExactly(25, 25, 74, 74); } @@ -409,9 +357,7 @@ class CropControllerTest { image.setRGB(99, 0, 0x000000); image.setRGB(0, 99, 0x000000); image.setRGB(99, 99, 0x000000); - int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image); - assertThat(bounds).containsExactly(0, 0, 99, 99); } @@ -422,9 +368,7 @@ class CropControllerTest { image.setRGB(10, 10, 0xF0F0F0); image.setRGB(90, 90, 0xF0F0F0); drawBlackRectangle(image, 30, 30, 70, 70); - int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image); - assertThat(bounds).containsExactly(10, 9, 90, 89); } @@ -433,9 +377,7 @@ class CropControllerTest { void shouldTreatGrayPixelsAsContent() throws Exception { BufferedImage image = createImageFilledWith(50, 50, 0xF0F0F0); drawDarkerRectangle(image, 20, 20, 30, 30, 0xC0C0C0); - int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image); - assertThat(bounds).containsExactly(0, 0, 49, 49); } } @@ -554,33 +496,6 @@ class CropControllerTest { @DisplayName("Error Handling") class ErrorHandlingTests { - @Test - @DisplayName("Should throw exception for corrupt PDF file") - void shouldThrowExceptionForCorruptPdf() throws IOException { - MockMultipartFile corruptFile = - new MockMultipartFile( - "fileInput", - "corrupt.pdf", - MediaType.APPLICATION_PDF_VALUE, - "not a valid pdf content".getBytes()); - - CropPdfForm request = - new CropRequestBuilder() - .withFile(corruptFile) - .withCoordinates(50f, 50f, 512f, 692f) - .withRemoveDataOutsideCrop(false) - .withAutoCrop(false) - .build(); - - when(pdfDocumentFactory.load(request)).thenThrow(new IOException("Invalid PDF format")); - - assertThatThrownBy(() -> cropController.cropPdf(request)) - .isInstanceOf(IOException.class) - .hasMessageContaining("Invalid PDF format"); - - verify(pdfDocumentFactory).load(request); - } - @Test @DisplayName("Should throw exception when coordinates are missing for manual crop") void shouldThrowExceptionForMissingCoordinates() throws IOException { @@ -593,54 +508,6 @@ class CropControllerTest { .hasMessage( "Crop coordinates (x, y, width, height) are required when auto-crop is not enabled"); } - - @Test - @DisplayName("Should handle negative coordinates gracefully") - void shouldHandleNegativeCoordinates() throws IOException { - MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf"); - CropPdfForm request = - new CropRequestBuilder() - .withFile(testFile) - .withCoordinates(-10f, 50f, 512f, 692f) - .withRemoveDataOutsideCrop(false) - .withAutoCrop(false) - .build(); - - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - - assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException(); - - verify(mockDocument, times(1)).close(); - verify(newDocument, times(1)).close(); - } - - @Test - @DisplayName("Should handle zero width or height") - void shouldHandleZeroDimensions() throws IOException { - MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf"); - CropPdfForm request = - new CropRequestBuilder() - .withFile(testFile) - .withCoordinates(50f, 50f, 0f, 692f) - .withRemoveDataOutsideCrop(false) - .withAutoCrop(false) - .build(); - - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - - assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException(); - - verify(mockDocument, times(1)).close(); - verify(newDocument, times(1)).close(); - } } @Nested @@ -663,7 +530,6 @@ class CropControllerTest { MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf"); float expectedWidth = 400f; float expectedHeight = 500f; - CropPdfForm request = new CropRequestBuilder() .withFile(testFile) @@ -672,16 +538,14 @@ class CropControllerTest { .withAutoCrop(false) .build(); - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - ResponseEntity response = cropController.cropPdf(request); - assertThat(response).isNotNull(); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + PDRectangle mediaBox = result.getPage(0).getMediaBox(); + assertThat(mediaBox.getWidth()).isCloseTo(expectedWidth, within(0.5f)); + assertThat(mediaBox.getHeight()).isCloseTo(expectedHeight, within(0.5f)); + } } @ParameterizedTest @@ -691,7 +555,6 @@ class CropControllerTest { throws IOException { PDRectangle pageSize = getPageSize(pageSizeName); MockMultipartFile testFile = pdfFactory.createPdfWithSize(filename, pageSize); - CropPdfForm request = new CropRequestBuilder() .withFile(testFile) @@ -700,17 +563,14 @@ class CropControllerTest { .withAutoCrop(false) .build(); - PDDocument mockDocument = mock(PDDocument.class); - PDDocument newDocument = mock(PDDocument.class); - when(pdfDocumentFactory.load(request)).thenReturn(mockDocument); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument)) - .thenReturn(newDocument); - ResponseEntity response = cropController.cropPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(mockDocument, times(1)).close(); - verify(newDocument, times(1)).close(); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + PDRectangle mediaBox = result.getPage(0).getMediaBox(); + assertThat(mediaBox.getWidth()).isCloseTo(300f, within(0.5f)); + assertThat(mediaBox.getHeight()).isCloseTo(400f, within(0.5f)); + } } } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java index 815309b889..e5dffcd5cc 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/ScalePagesControllerTest.java @@ -1,6 +1,6 @@ package stirling.software.SPDF.controller.api; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -10,17 +10,21 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import org.apache.pdfbox.Loader; 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.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,264 +32,211 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import stirling.software.SPDF.model.api.general.ScalePagesRequest; -import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) +@Tag("integration") class ScalePagesControllerTest { - private static ResponseEntity streamingOk(byte[] bytes) { - return ResponseEntity.ok(new ByteArrayResource(bytes)); - } - private static byte[] drainBody(ResponseEntity response) throws java.io.IOException { + private static byte[] drainBody(ResponseEntity response) throws IOException { java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - try (java.io.InputStream __in = response.getBody().getInputStream()) { - __in.transferTo(baos); + try (java.io.InputStream in = response.getBody().getInputStream()) { + in.transferTo(baos); } return baos.toByteArray(); } @TempDir Path tempDir; - @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private TempFileManager tempFileManager; @InjectMocks private ScalePagesController controller; @BeforeEach void setUp() throws Exception { lenient() - .when(tempFileManager.createManagedTempFile(anyString())) + .when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class))) .thenAnswer( inv -> { - File f = - Files.createTempFile("test", inv.getArgument(0)) - .toFile(); - TempFile tf = mock(TempFile.class); - lenient().when(tf.getFile()).thenReturn(f); - lenient().when(tf.getPath()).thenReturn(f.toPath()); - return tf; + MultipartFile mf = inv.getArgument(0); + File f = Files.createTempFile("scale-in", ".pdf").toFile(); + Files.write(f.toPath(), mf.getBytes()); + return f; }); + lenient() + .when(tempFileManager.createTempFile(anyString())) + .thenAnswer(inv -> Files.createTempFile("scale-out", inv.getArgument(0)).toFile()); } - private byte[] createRealPdf(PDRectangle pageSize, int numPages) throws IOException { + // PDFium TransFormWithClip requires each page to have a content stream; add a + // sentinel character on every page so the test fixtures are not empty. + private MockMultipartFile createRealPdf(PDRectangle pageSize, int numPages) throws IOException { + PDType1Font helv = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try (PDDocument doc = new PDDocument()) { for (int i = 0; i < numPages; i++) { - doc.addPage(new PDPage(pageSize)); + PDPage page = new PDPage(pageSize); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(helv, 12); + cs.newLineAtOffset(50, pageSize.getHeight() - 50); + cs.showText("X"); + cs.endText(); + } } - Path pdfPath = tempDir.resolve("input.pdf"); + Path pdfPath = tempDir.resolve("input-" + System.nanoTime() + ".pdf"); doc.save(pdfPath.toFile()); - return Files.readAllBytes(pdfPath); + return new MockMultipartFile( + "fileInput", + "test.pdf", + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(pdfPath)); } } - private void setupFactory() throws IOException { - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer( - inv -> { - byte[] bytes = ((MultipartFile) inv.getArgument(0)).getBytes(); - return org.apache.pdfbox.Loader.loadPDF(bytes); - }); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); + private ScalePagesRequest buildRequest(MockMultipartFile file, String size, float factor) { + ScalePagesRequest request = new ScalePagesRequest(); + request.setFileInput(file); + request.setPageSize(size); + request.setScaleFactor(factor); + return request; } @Test void testScalePages_A4ToA3() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = controller.scalePages(buildRequest(file, "A3", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("A3"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); - assertNotNull(response.getBody()); - assertTrue(drainBody(response).length > 0); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + assertThat(out.getNumberOfPages()).isEqualTo(1); + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A3.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A3.getHeight(), within(0.5f)); + } } @Test void testScalePages_KeepSize() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 2); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 2); + ResponseEntity response = controller.scalePages(buildRequest(file, "KEEP", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("KEEP"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + assertThat(out.getNumberOfPages()).isEqualTo(2); + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f)); + } } @Test void testScalePages_WithScaleFactor() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = controller.scalePages(buildRequest(file, "A4", 0.5f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("A4"); - request.setScaleFactor(0.5f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + // Page size stays A4; content is scaled by 0.5x and centered. + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f)); + } } @Test void testScalePages_Letter() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = + controller.scalePages(buildRequest(file, "LETTER", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("LETTER"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.LETTER.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.LETTER.getHeight(), within(0.5f)); + } } @Test void testScalePages_Legal() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = + controller.scalePages(buildRequest(file, "LEGAL", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("LEGAL"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.LEGAL.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.LEGAL.getHeight(), within(0.5f)); + } } @Test void testScalePages_InvalidPageSize() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ScalePagesRequest request = buildRequest(file, "INVALID_SIZE", 1.0f); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("INVALID_SIZE"); - request.setScaleFactor(1.0f); - - setupFactory(); - - assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request)); + assertThatThrownBy(() -> controller.scalePages(request)) + .isInstanceOf(IllegalArgumentException.class); } @Test void testScalePages_MultiplePages() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 5); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 5); + ResponseEntity response = controller.scalePages(buildRequest(file, "A5", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("A5"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + assertThat(out.getNumberOfPages()).isEqualTo(5); + for (int i = 0; i < 5; i++) { + PDRectangle mb = out.getPage(i).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A5.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A5.getHeight(), within(0.5f)); + } + } } @Test void testScalePages_LandscapeSize() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = + controller.scalePages(buildRequest(file, "A4_LANDSCAPE", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("A4_LANDSCAPE"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f)); + } } @Test void testScalePages_KeepWithEmptyDoc() throws Exception { - // Create a PDF then load it, but mock factory to return empty doc for KEEP check - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); + Path pdfPath = tempDir.resolve("empty.pdf"); + try (PDDocument doc = new PDDocument()) { + doc.save(pdfPath.toFile()); + } MockMultipartFile file = new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + "fileInput", + "empty.pdf", + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(pdfPath)); + ScalePagesRequest request = buildRequest(file, "KEEP", 1.0f); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("KEEP"); - request.setScaleFactor(1.0f); - - // Return an empty document to trigger the KEEP exception - when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(new PDDocument()); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); - - assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request)); + assertThatThrownBy(() -> controller.scalePages(request)) + .isInstanceOf(IllegalArgumentException.class); } @Test void testScalePages_A0Size() throws Exception { - byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1); - MockMultipartFile file = - new MockMultipartFile( - "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + MockMultipartFile file = createRealPdf(PDRectangle.A4, 1); + ResponseEntity response = controller.scalePages(buildRequest(file, "A0", 1.0f)); - ScalePagesRequest request = new ScalePagesRequest(); - request.setFileInput(file); - request.setPageSize("A0"); - request.setScaleFactor(1.0f); - - setupFactory(); - - ResponseEntity response = controller.scalePages(request); - - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); + assertThat(response.getStatusCode().value()).isEqualTo(200); + try (PDDocument out = Loader.loadPDF(drainBody(response))) { + PDRectangle mb = out.getPage(0).getMediaBox(); + assertThat(mb.getWidth()).isCloseTo(PDRectangle.A0.getWidth(), within(0.5f)); + assertThat(mb.getHeight()).isCloseTo(PDRectangle.A0.getHeight(), within(0.5f)); + } } }