command =
@@ -152,19 +303,34 @@ public class CropController {
return WebResponseUtils.bytesToWebResponse(
pdfContent,
- request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
- + "_cropped.pdf");
+ GeneralUtils.generateFilename(
+ request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Ghostscript processing was interrupted", e);
} finally {
- try {
+ if (tempInputFile != null) {
Files.deleteIfExists(tempInputFile);
+ }
+ if (tempOutputFile != null) {
Files.deleteIfExists(tempOutputFile);
- } catch (IOException e) {
- log.debug("Failed to delete temporary files", e);
}
}
}
+
+ private record CropBounds(float x, float y, float width, float height) {
+
+ static CropBounds fromPixels(int[] pixelBounds, float scaleX, float scaleY) {
+ if (pixelBounds.length != 4) {
+ throw new IllegalArgumentException(
+ "pixelBounds array must contain exactly 4 elements: [x1, y1, x2, y2]");
+ }
+ float x = pixelBounds[0] * scaleX;
+ float y = pixelBounds[1] * scaleY;
+ float width = (pixelBounds[2] - pixelBounds[0]) * scaleX;
+ float height = (pixelBounds[3] - pixelBounds[1]) * scaleY;
+ return new CropBounds(x, y, width, height);
+ }
+ }
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java
index 480169468..16d10b942 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java
@@ -14,21 +14,24 @@ public class CropPdfForm extends PDFFile {
@Schema(
description = "The x-coordinate of the top-left corner of the crop area",
type = "number")
- private float x;
+ private Float x;
@Schema(
description = "The y-coordinate of the top-left corner of the crop area",
type = "number")
- private float y;
+ private Float y;
@Schema(description = "The width of the crop area", type = "number")
- private float width;
+ private Float width;
@Schema(description = "The height of the crop area", type = "number")
- private float height;
+ private Float height;
@Schema(
description = "Whether to remove text outside the crop area (keeps images)",
type = "boolean")
private boolean removeDataOutsideCrop = true;
+
+ @Schema(description = "Enable auto-crop to detect and remove white space", type = "boolean")
+ private boolean autoCrop = false;
}
diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties
index eebbeb78f..dc4dbe96e 100644
--- a/app/core/src/main/resources/messages_en_GB.properties
+++ b/app/core/src/main/resources/messages_en_GB.properties
@@ -1143,7 +1143,7 @@ adjustContrast.download=Download
crop.title=Crop
crop.header=Crop PDF
crop.submit=Submit
-
+crop.autoCrop=Auto-crop (detect and remove white space)
#autoSplitPDF
autoSplitPDF.title=Auto Split PDF
diff --git a/app/core/src/main/resources/static/js/pages/crop.js b/app/core/src/main/resources/static/js/pages/crop.js
index 1854023a0..c61cbaecc 100644
--- a/app/core/src/main/resources/static/js/pages/crop.js
+++ b/app/core/src/main/resources/static/js/pages/crop.js
@@ -2,7 +2,6 @@ let pdfCanvas = document.getElementById('cropPdfCanvas');
let overlayCanvas = document.getElementById('overlayCanvas');
let canvasesContainer = document.getElementById('canvasesContainer');
canvasesContainer.style.display = 'none';
-let containerRect = canvasesContainer.getBoundingClientRect();
let context = pdfCanvas.getContext('2d');
let overlayContext = overlayCanvas.getContext('2d');
@@ -30,9 +29,16 @@ let rectHeight = 0;
let pageScale = 1; // The scale which the pdf page renders
let timeId = null; // timeout id for resizing canvases event
+let currentRenderTask = null; // Track current PDF render task to cancel if needed
function renderPageFromFile(file) {
if (file.type === 'application/pdf') {
+ // Cancel any ongoing render task when loading a new file
+ if (currentRenderTask) {
+ currentRenderTask.cancel();
+ currentRenderTask = null;
+ }
+
let reader = new FileReader();
reader.onload = function (ev) {
let typedArray = new Uint8Array(reader.result);
@@ -51,7 +57,7 @@ window.addEventListener('resize', function () {
clearTimeout(timeId);
timeId = setTimeout(function () {
- if (fileInput.files.length == 0) return;
+ if (!pdfDoc) return; // Only resize if we have a PDF loaded
let canvasesContainer = document.getElementById('canvasesContainer');
let containerRect = canvasesContainer.getBoundingClientRect();
@@ -59,35 +65,33 @@ window.addEventListener('resize', function () {
overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
- pdfCanvas.width = containerRect.width;
- pdfCanvas.height = containerRect.height;
-
- overlayCanvas.width = containerRect.width;
- overlayCanvas.height = containerRect.height;
-
- let file = fileInput.files[0];
- renderPageFromFile(file);
+ // Re-render with new container size
+ renderPage(currentPage);
}, 1000);
});
-fileInput.addEventListener('change', function (e) {
- fileInput.addEventListener('file-input-change', async (e) => {
- const {allFiles} = e.detail;
- if (allFiles && allFiles.length > 0) {
- canvasesContainer.style.display = 'block'; // set for visual purposes
+fileInput.addEventListener('file-input-change', async (e) => {
+ if (!e.detail) return; // Guard against null detail
+ const {allFiles} = e.detail;
+ if (allFiles && allFiles.length > 0) {
+ canvasesContainer.style.display = 'block'; // set for visual purposes
+
+ // Wait for the layout to be updated before rendering
+ setTimeout(() => {
let file = allFiles[0];
renderPageFromFile(file);
- }
- });
+ }, 100);
+ }
});
cropForm.addEventListener('submit', function (e) {
if (xInput.value == '' && yInput.value == '' && widthInput.value == '' && heightInput.value == '') {
- // Ορίστε συντεταγμένες για ολόκληρη την επιφάνεια του PDF
+ // Set coordinates for the entire PDF surface
+ let currentContainerRect = canvasesContainer.getBoundingClientRect();
xInput.value = 0;
yInput.value = 0;
- widthInput.value = containerRect.width;
- heightInput.value = containerRect.height;
+ widthInput.value = currentContainerRect.width;
+ heightInput.value = currentContainerRect.height;
}
});
@@ -135,16 +139,24 @@ overlayCanvas.addEventListener('mouseup', function (e) {
});
function renderPage(pageNumber) {
+ // Cancel any ongoing render task
+ if (currentRenderTask) {
+ currentRenderTask.cancel();
+ currentRenderTask = null;
+ }
+
pdfDoc.getPage(pageNumber).then(function (page) {
let canvasesContainer = document.getElementById('canvasesContainer');
let containerRect = canvasesContainer.getBoundingClientRect();
pageScale = containerRect.width / page.getViewport({scale: 1}).width; // The new scale
- let viewport = page.getViewport({scale: containerRect.width / page.getViewport({scale: 1}).width});
+ // Normalize rotation to 0, 90, 180, or 270 degrees
+ let normalizedRotation = ((page.rotate % 360) + 360) % 360;
+ let viewport = page.getViewport({scale: pageScale, rotation: normalizedRotation});
- canvasesContainer.width = viewport.width;
- canvasesContainer.height = viewport.height;
+ // Don't set container width, let CSS handle it
+ canvasesContainer.style.height = viewport.height + 'px';
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
@@ -152,8 +164,21 @@ function renderPage(pageNumber) {
overlayCanvas.width = viewport.width; // Match overlay canvas size with PDF canvas
overlayCanvas.height = viewport.height;
+ context.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
+
+ context.fillStyle = 'white';
+ context.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height);
+
let renderContext = {canvasContext: context, viewport: viewport};
- page.render(renderContext);
- pdfCanvas.classList.add('shadow-canvas');
+ currentRenderTask = page.render(renderContext);
+ currentRenderTask.promise.then(function() {
+ currentRenderTask = null;
+ pdfCanvas.classList.add('shadow-canvas');
+ }).catch(function(error) {
+ if (error.name !== 'RenderingCancelledException') {
+ console.error('PDF rendering error:', error);
+ }
+ currentRenderTask = null;
+ });
});
}
diff --git a/app/core/src/main/resources/templates/crop.html b/app/core/src/main/resources/templates/crop.html
index e91c481c3..6487b296d 100644
--- a/app/core/src/main/resources/templates/crop.html
+++ b/app/core/src/main/resources/templates/crop.html
@@ -23,6 +23,12 @@
+
+
+
+
+
+
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
new file mode 100644
index 000000000..3c526a84c
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java
@@ -0,0 +1,686 @@
+package stirling.software.SPDF.controller.api;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.lang.reflect.Method;
+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.*;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.mock.web.MockMultipartFile;
+
+import stirling.software.SPDF.model.api.general.CropPdfForm;
+import stirling.software.common.service.CustomPDFDocumentFactory;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CropController Tests")
+class CropControllerTest {
+
+ @TempDir Path tempDir;
+ @Mock private CustomPDFDocumentFactory pdfDocumentFactory;
+ @InjectMocks private CropController cropController;
+ private TestPdfFactory pdfFactory;
+
+ @BeforeEach
+ void setUp() {
+ pdfFactory = new TestPdfFactory();
+ }
+
+ private static class CropRequestBuilder {
+ private final CropPdfForm form = new CropPdfForm();
+
+ CropRequestBuilder withFile(MockMultipartFile file) {
+ form.setFileInput(file);
+ return this;
+ }
+
+ CropRequestBuilder withCoordinates(float x, float y, float width, float height) {
+ form.setX(x);
+ form.setY(y);
+ form.setWidth(width);
+ form.setHeight(height);
+ return this;
+ }
+
+ CropRequestBuilder withAutoCrop(boolean autoCrop) {
+ form.setAutoCrop(autoCrop);
+ return this;
+ }
+
+ CropRequestBuilder withRemoveDataOutsideCrop(boolean remove) {
+ form.setRemoveDataOutsideCrop(remove);
+ return this;
+ }
+
+ CropPdfForm build() {
+ return form;
+ }
+ }
+
+ private class TestPdfFactory {
+ private static final PDType1Font HELVETICA =
+ new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+
+ MockMultipartFile createStandardPdf(String filename) throws IOException {
+ return createPdf(filename, PDRectangle.LETTER, null);
+ }
+
+ MockMultipartFile createPdfWithContent(String filename, String content) throws IOException {
+ return createPdf(filename, PDRectangle.LETTER, content);
+ }
+
+ MockMultipartFile createPdfWithSize(String filename, PDRectangle size) throws IOException {
+ return createPdf(filename, size, null);
+ }
+
+ 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();
+ }
+ }
+
+ doc.save(testPdfPath.toFile());
+ }
+
+ return new MockMultipartFile(
+ "fileInput",
+ filename,
+ MediaType.APPLICATION_PDF_VALUE,
+ Files.readAllBytes(testPdfPath));
+ }
+
+ MockMultipartFile createPdfWithCenteredContent(String filename, String content)
+ 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);
+ float x = pageSize.getWidth() / 2 - 50;
+ float y = pageSize.getHeight() / 2;
+ contentStream.newLineAtOffset(x, y);
+ contentStream.showText(content);
+ contentStream.endText();
+ }
+ }
+
+ doc.save(testPdfPath.toFile());
+ }
+
+ return new MockMultipartFile(
+ "fileInput",
+ filename,
+ MediaType.APPLICATION_PDF_VALUE,
+ Files.readAllBytes(testPdfPath));
+ }
+ }
+
+ @Nested
+ @DisplayName("Manual Crop with PDFBox")
+ class ManualCropPDFBoxTests {
+
+ @Test
+ @DisplayName(
+ "Should successfully crop PDF using PDFBox when removeDataOutsideCrop is false")
+ void shouldCropPdfSuccessfullyWithPDFBox() throws IOException {
+ MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
+ CropPdfForm request =
+ new CropRequestBuilder()
+ .withFile(testFile)
+ .withCoordinates(50f, 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);
+
+ 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();
+ }
+
+ @ParameterizedTest
+ @CsvSource({"50, 50, 512, 692", "0, 0, 300, 400", "100, 100, 400, 600"})
+ @DisplayName("Should handle various coordinate sets correctly")
+ void shouldHandleVariousCoordinates(float x, float y, float width, float height)
+ throws IOException {
+ MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
+ CropPdfForm request =
+ new CropRequestBuilder()
+ .withFile(testFile)
+ .withCoordinates(x, y, width, height)
+ .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();
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+
+ verify(pdfDocumentFactory).load(request);
+ verify(mockDocument, times(1)).close();
+ verify(newDocument, times(1)).close();
+ }
+ }
+
+ @Nested
+ @DisplayName("Auto Crop Functionality")
+ @Tag("integration")
+ class AutoCropTests {
+
+ private TestPdfFactory autoCropPdfFactory;
+
+ @BeforeEach
+ void setUp() {
+ autoCropPdfFactory = new TestPdfFactory();
+ }
+
+ @Test
+ @DisplayName("Should auto-crop PDF with content successfully")
+ void shouldAutoCropPdfSuccessfully() throws IOException {
+ MockMultipartFile testFile =
+ autoCropPdfFactory.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);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotEmpty();
+
+ try (PDDocument result = Loader.loadPDF(response.getBody())) {
+ assertThat(result.getNumberOfPages()).isEqualTo(1);
+
+ PDPage page = result.getPage(0);
+ assertThat(page).isNotNull();
+ assertThat(page.getMediaBox()).isNotNull();
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Should handle PDF with minimal content")
+ void shouldHandleMinimalContentPdf() throws IOException {
+ MockMultipartFile testFile =
+ autoCropPdfFactory.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);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+
+ Assertions.assertNotNull(response.getBody());
+ try (PDDocument result = Loader.loadPDF(response.getBody())) {
+ assertThat(result.getNumberOfPages()).isEqualTo(1);
+ }
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("Content Bounds Detection")
+ class ContentBoundsDetectionTests {
+
+ private Method detectContentBoundsMethod;
+
+ private static BufferedImage createWhiteImage(int width, int height) {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ image.setRGB(x, y, 0xFFFFFF);
+ }
+ }
+ return image;
+ }
+
+ private static BufferedImage createImageFilledWith(int width, int height, int color) {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ image.setRGB(x, y, color);
+ }
+ }
+ return image;
+ }
+
+ private static void drawBlackRectangle(
+ BufferedImage image, int x1, int y1, int x2, int y2) {
+ for (int x = x1; x < x2; x++) {
+ for (int y = y1; y < y2; y++) {
+ image.setRGB(x, y, 0x000000);
+ }
+ }
+ }
+
+ private static void drawDarkerRectangle(
+ BufferedImage image, int x1, int y1, int x2, int y2, int color) {
+ for (int x = x1; x < x2; x++) {
+ for (int y = y1; y < y2; y++) {
+ image.setRGB(x, y, color);
+ }
+ }
+ }
+
+ @BeforeEach
+ void setUp() throws NoSuchMethodException {
+ detectContentBoundsMethod =
+ CropController.class.getDeclaredMethod(
+ "detectContentBounds", BufferedImage.class);
+ detectContentBoundsMethod.setAccessible(true);
+ }
+
+ @Test
+ @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);
+ }
+
+ @Test
+ @DisplayName("Should detect black rectangle bounds correctly")
+ 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);
+ }
+
+ @Test
+ @DisplayName("Should detect content at image edges")
+ void shouldDetectContentAtEdges() throws Exception {
+ BufferedImage image = createWhiteImage(100, 100);
+ image.setRGB(0, 0, 0x000000);
+ 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);
+ }
+
+ @Test
+ @DisplayName("Should include noise pixels in bounds detection")
+ void shouldIncludeNoiseInBounds() throws Exception {
+ BufferedImage image = createWhiteImage(100, 100);
+ 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);
+ }
+
+ @Test
+ @DisplayName("Should treat gray pixels below threshold as content")
+ 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);
+ }
+ }
+
+ @Nested
+ @DisplayName("White Pixel Detection")
+ class WhitePixelDetectionTests {
+
+ private Method isWhiteMethod;
+
+ @BeforeEach
+ void setUp() throws NoSuchMethodException {
+ isWhiteMethod = CropController.class.getDeclaredMethod("isWhite", int.class, int.class);
+ isWhiteMethod.setAccessible(true);
+ }
+
+ @Test
+ @DisplayName("Should identify pure white pixels")
+ void shouldIdentifyWhitePixels() throws Exception {
+ assertThat((Boolean) isWhiteMethod.invoke(null, 0xFFFFFFFF, 250)).isTrue();
+ assertThat((Boolean) isWhiteMethod.invoke(null, 0xFFF0F0F0, 240)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should identify black pixels as non-white")
+ void shouldIdentifyBlackPixels() throws Exception {
+ assertThat((Boolean) isWhiteMethod.invoke(null, 0xFF000000, 250)).isFalse();
+ assertThat((Boolean) isWhiteMethod.invoke(null, 0xFF101010, 250)).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0xFFFFFFFF, 0xFFFAFAFA, 0xFFF5F5F5})
+ @DisplayName("Should identify various white shades")
+ void shouldIdentifyVariousWhiteShades(int pixelColor) throws Exception {
+ assertThat((Boolean) isWhiteMethod.invoke(null, pixelColor, 240)).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0xFF000000, 0xFF101010, 0xFF808080})
+ @DisplayName("Should identify various non-white shades")
+ void shouldIdentifyNonWhiteShades(int pixelColor) throws Exception {
+ assertThat((Boolean) isWhiteMethod.invoke(null, pixelColor, 250)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("CropBounds Conversion")
+ class CropBoundsTests {
+
+ private Class> cropBoundsClass;
+ private Method fromPixelsMethod;
+
+ @BeforeEach
+ void setUp() throws ClassNotFoundException, NoSuchMethodException {
+ cropBoundsClass =
+ Class.forName(
+ "stirling.software.SPDF.controller.api.CropController$CropBounds");
+ fromPixelsMethod =
+ cropBoundsClass.getDeclaredMethod(
+ "fromPixels", int[].class, float.class, float.class);
+ fromPixelsMethod.setAccessible(true);
+ }
+
+ @Test
+ @DisplayName("Should convert pixel bounds to PDF coordinates correctly")
+ void shouldConvertPixelBoundsToPdfCoordinates() throws Exception {
+ int[] pixelBounds = {10, 20, 110, 120};
+ float scaleX = 0.5f;
+ float scaleY = 0.5f;
+
+ Object bounds = fromPixelsMethod.invoke(null, pixelBounds, scaleX, scaleY);
+
+ assertThat(getFloatField(bounds, "x")).isCloseTo(5.0f, within(0.01f));
+ assertThat(getFloatField(bounds, "y")).isCloseTo(10.0f, within(0.01f));
+ assertThat(getFloatField(bounds, "width")).isCloseTo(50.0f, within(0.01f));
+ assertThat(getFloatField(bounds, "height")).isCloseTo(50.0f, within(0.01f));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "0, 0, 100, 100, 1.0, 1.0",
+ "10, 20, 50, 80, 2.0, 2.0",
+ "5, 5, 25, 25, 0.5, 0.5"
+ })
+ @DisplayName("Should handle various scale factors")
+ void shouldHandleVariousScaleFactors(
+ int x1, int y1, int x2, int y2, float scaleX, float scaleY) throws Exception {
+ int[] pixelBounds = {x1, y1, x2, y2};
+
+ Object bounds = fromPixelsMethod.invoke(null, pixelBounds, scaleX, scaleY);
+
+ assertThat(bounds).isNotNull();
+ assertThat(getFloatField(bounds, "width")).isGreaterThan(0);
+ assertThat(getFloatField(bounds, "height")).isGreaterThan(0);
+ }
+
+ @Test
+ @DisplayName("Should throw exception for invalid pixel bounds array")
+ void shouldThrowExceptionForInvalidArray() {
+ int[] invalidBounds = {10, 20, 30};
+
+ assertThatThrownBy(() -> fromPixelsMethod.invoke(null, invalidBounds, 1.0f, 1.0f))
+ .isInstanceOf(Exception.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .cause()
+ .hasMessageContaining("pixelBounds array must contain exactly 4 elements");
+ }
+
+ private float getFloatField(Object obj, String fieldName) throws Exception {
+ Method getter = cropBoundsClass.getDeclaredMethod(fieldName);
+ return (Float) getter.invoke(obj);
+ }
+ }
+
+ @Nested
+ @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 {
+ MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
+ CropPdfForm request =
+ new CropRequestBuilder().withFile(testFile).withAutoCrop(false).build();
+
+ assertThatThrownBy(() -> cropController.cropPdf(request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .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
+ @DisplayName("PDF Content Verification")
+ @Tag("integration")
+ class PdfContentVerificationTests {
+
+ private static PDRectangle getPageSize(String name) {
+ return switch (name) {
+ case "LETTER" -> PDRectangle.LETTER;
+ case "A4" -> PDRectangle.A4;
+ case "LEGAL" -> PDRectangle.LEGAL;
+ default -> PDRectangle.LETTER;
+ };
+ }
+
+ @Test
+ @DisplayName("Should produce PDF with correct dimensions after crop")
+ void shouldProducePdfWithCorrectDimensions() throws IOException {
+ MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
+ float expectedWidth = 400f;
+ float expectedHeight = 500f;
+
+ CropPdfForm request =
+ new CropRequestBuilder()
+ .withFile(testFile)
+ .withCoordinates(50f, 50f, expectedWidth, expectedHeight)
+ .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();
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"test1.pdf, LETTER", "test2.pdf, A4", "test3.pdf, LEGAL"})
+ @DisplayName("Should handle different page sizes")
+ void shouldHandleDifferentPageSizes(String filename, String pageSizeName)
+ throws IOException {
+ PDRectangle pageSize = getPageSize(pageSizeName);
+ MockMultipartFile testFile = pdfFactory.createPdfWithSize(filename, pageSize);
+
+ CropPdfForm request =
+ new CropRequestBuilder()
+ .withFile(testFile)
+ .withCoordinates(50f, 50f, 300f, 400f)
+ .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.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(mockDocument, times(1)).close();
+ verify(newDocument, times(1)).close();
+ }
+ }
+}