impl migration to pdfium for crop and scale

This commit is contained in:
Anthony Stirling
2026-05-22 09:45:43 +01:00
parent 2f1fe2c80c
commit 548c8a7e06
4 changed files with 404 additions and 638 deletions
@@ -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<Resource> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
private ResponseEntity<Resource> 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<Resource> 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<Resource> 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<Resource> 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<Resource> 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<String> 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);
}
}
@@ -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<String, PDRectangle> sizeMap = getSizeMap();
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
Map<String, PageSize> sizeMap = getSizeMap();
PageSize size = sizeMap.get(name);
if (size == null) {
throw ExceptionUtils.createInvalidPageSizeException(name);
}
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
return size;
}
private static Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
private static Map<String, PageSize> getSizeMap() {
Map<String, PageSize> 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);
}
}
}
@@ -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<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
private static byte[] drainBody(ResponseEntity<Resource> 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.<String>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<Resource> 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<Resource> 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<Resource> response = cropController.cropPdf(request);
ResponseEntity<Resource> 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<Resource> response = cropController.cropPdf(request);
ResponseEntity<Resource> 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<Resource> 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<Resource> 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));
}
}
}
}
@@ -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<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
private static byte[] drainBody(ResponseEntity<Resource> 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.<String>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<Resource> response = controller.scalePages(buildRequest(file, "A3", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A3");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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<Resource> response = controller.scalePages(buildRequest(file, "KEEP", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("KEEP");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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<Resource> response = controller.scalePages(buildRequest(file, "A4", 0.5f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setScaleFactor(0.5f);
setupFactory();
ResponseEntity<Resource> 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<Resource> response =
controller.scalePages(buildRequest(file, "LETTER", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LETTER");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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<Resource> response =
controller.scalePages(buildRequest(file, "LEGAL", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LEGAL");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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<Resource> response = controller.scalePages(buildRequest(file, "A5", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A5");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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<Resource> 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<Resource> 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<Resource> response = controller.scalePages(buildRequest(file, "A0", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A0");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> 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));
}
}
}