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 8ca9604ce..e22a04098 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,5 +1,6 @@ package stirling.software.SPDF.controller.api; +import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; @@ -13,6 +14,7 @@ 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.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; @@ -39,8 +41,86 @@ import stirling.software.common.util.WebResponseUtils; @Slf4j 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 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}; + } + + // Sample every nth pixel for large images to reduce processing time + int step = (width > 2000 || height > 2000) ? 2 : 1; + + int top = 0; + boolean found = false; + for (int y = 0; y < height && !found; y += step) { + for (int x = 0; x < width; x += step) { + if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) { + top = y; + found = true; + break; + } + } + } + + int bottom = height - 1; + found = false; + for (int y = height - 1; y >= 0 && !found; y -= step) { + for (int x = 0; x < width; x += step) { + if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) { + bottom = y; + found = true; + break; + } + } + } + + int left = 0; + found = false; + for (int x = 0; x < width && !found; x += step) { + for (int y = top; y <= bottom; y += step) { + if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) { + left = x; + found = true; + break; + } + } + } + + int right = width - 1; + found = false; + for (int x = width - 1; x >= 0 && !found; x -= step) { + for (int y = top; y <= bottom; y += step) { + if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) { + right = x; + found = true; + break; + } + } + } + + // Return bounds in format: [left, bottom, right, top] + // Note: Image coordinates are top-down, PDF coordinates are bottom-up + return new int[] {left, height - bottom - 1, right, height - top - 1}; + } + + private static boolean isWhite(int rgb, int threshold) { + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + return r >= threshold && g >= threshold && b >= threshold; + } + @PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Crops a PDF document", @@ -48,6 +128,18 @@ public class CropController { "This operation takes an input PDF file and crops it according to the given" + " coordinates. Input:PDF Output:PDF Type:SISO") public ResponseEntity cropPdf(@ModelAttribute CropPdfForm request) throws IOException { + if (request.isAutoCrop()) { + return cropWithAutomaticDetection(request); + } + + if (request.getX() == null + || request.getY() == null + || request.getWidth() == null + || request.getHeight() == null) { + throw new IllegalArgumentException( + "Crop coordinates (x, y, width, height) are required when auto-crop is not enabled"); + } + if (request.isRemoveDataOutsideCrop()) { return cropWithGhostscript(request); } else { @@ -55,86 +147,145 @@ public class CropController { } } + private ResponseEntity cropWithAutomaticDetection(@ModelAttribute CropPdfForm request) + throws IOException { + try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { + + try (PDDocument newDocument = + pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { + PDFRenderer renderer = new PDFRenderer(sourceDocument); + LayerUtility layerUtility = new LayerUtility(newDocument); + + 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(); + + 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)); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + newDocument.save(baos); + byte[] pdfContent = baos.toByteArray(); + + return WebResponseUtils.bytesToWebResponse( + pdfContent, + GeneralUtils.generateFilename( + request.getFileInput().getOriginalFilename(), "_cropped.pdf")); + } + } + } + private ResponseEntity cropWithPDFBox(@ModelAttribute CropPdfForm request) throws IOException { - PDDocument sourceDocument = pdfDocumentFactory.load(request); + try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { - PDDocument newDocument = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); + try (PDDocument newDocument = + pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { + int totalPages = sourceDocument.getNumberOfPages(); + LayerUtility layerUtility = new LayerUtility(newDocument); - int totalPages = sourceDocument.getNumberOfPages(); + for (int i = 0; i < totalPages; i++) { + PDPage sourcePage = sourceDocument.getPage(i); - LayerUtility layerUtility = new LayerUtility(newDocument); + // 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); - for (int i = 0; i < totalPages; i++) { - PDPage sourcePage = sourceDocument.getPage(i); + contentStream.saveGraphicsState(); - // Create a new page with the size of the source page - PDPage newPage = new PDPage(sourcePage.getMediaBox()); - newDocument.addPage(newPage); - PDPageContentStream contentStream = - new PDPageContentStream(newDocument, newPage, AppendMode.OVERWRITE, true, true); + // Define the crop area + contentStream.addRect( + request.getX(), + request.getY(), + request.getWidth(), + request.getHeight()); + contentStream.clip(); - // Import the source page as a form XObject - PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i); + // Draw the entire formXObject + contentStream.drawForm(formXObject); - contentStream.saveGraphicsState(); + contentStream.restoreGraphicsState(); + } - // Define the crop area - contentStream.addRect( - request.getX(), request.getY(), request.getWidth(), request.getHeight()); - contentStream.clip(); + // Now, set the new page's media box to the cropped size + newPage.setMediaBox( + new PDRectangle( + request.getX(), + request.getY(), + request.getWidth(), + request.getHeight())); + } - // Draw the entire formXObject - contentStream.drawForm(formXObject); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + newDocument.save(baos); - contentStream.restoreGraphicsState(); - - contentStream.close(); - - // Now, set the new page's media box to the cropped size - newPage.setMediaBox( - new PDRectangle( - request.getX(), - request.getY(), - request.getWidth(), - request.getHeight())); + byte[] pdfContent = baos.toByteArray(); + return WebResponseUtils.bytesToWebResponse( + pdfContent, + GeneralUtils.generateFilename( + request.getFileInput().getOriginalFilename(), "_cropped.pdf")); + } } - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - newDocument.save(baos); - newDocument.close(); - sourceDocument.close(); - - byte[] pdfContent = baos.toByteArray(); - return WebResponseUtils.bytesToWebResponse( - pdfContent, - GeneralUtils.generateFilename( - request.getFileInput().getOriginalFilename(), "_cropped.pdf")); } private ResponseEntity cropWithGhostscript(@ModelAttribute CropPdfForm request) throws IOException { - PDDocument sourceDocument = pdfDocumentFactory.load(request); + Path tempInputFile = null; + Path tempOutputFile = null; - for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { - PDPage page = sourceDocument.getPage(i); - PDRectangle cropBox = - new PDRectangle( - request.getX(), - request.getY(), - request.getWidth(), - request.getHeight()); - page.setCropBox(cropBox); - } + try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { + for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { + PDPage page = sourceDocument.getPage(i); + PDRectangle cropBox = + new PDRectangle( + request.getX(), + request.getY(), + request.getWidth(), + request.getHeight()); + page.setCropBox(cropBox); + } - Path tempInputFile = Files.createTempFile("crop_input", ".pdf"); - Path tempOutputFile = Files.createTempFile("crop_output", ".pdf"); + tempInputFile = Files.createTempFile(TEMP_INPUT_PREFIX, PDF_EXTENSION); + tempOutputFile = Files.createTempFile(TEMP_OUTPUT_PREFIX, PDF_EXTENSION); - try { + // Save the source document with crop boxes sourceDocument.save(tempInputFile.toFile()); - sourceDocument.close(); + // Execute Ghostscript to process the crop boxes ProcessExecutor processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT); List 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(); + } + } +}