From d76f0ea91d1bbc9152a395e5017098dc9549cdcd Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Fri, 22 May 2026 09:43:00 +0100 Subject: [PATCH] impl migration to pdfium for attachments and remove images --- .../api/misc/AttachmentController.java | 54 ++++- .../api/misc/RemoveImagesController.java | 141 +++++-------- .../api/misc/AttachmentControllerTest.java | 120 ++++++++++- .../api/misc/RemoveImagesControllerTest.java | 193 ++++++++++++++++++ 4 files changed, 406 insertions(+), 102 deletions(-) create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java index 924013abad..4f6662dd64 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -35,6 +35,8 @@ 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.doc.Attachment; @MiscApi @Slf4j @@ -60,6 +62,7 @@ public class AttachmentController { "This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO") public ResponseEntity addAttachments(@ModelAttribute AddAttachmentRequest request) throws Exception { + // Hybrid: JPDFium add lacks viewer prefs, content-type and dates; PDFBox preserves them. MultipartFile fileInput = request.getFileInput(); List attachments = request.getAttachments(); boolean convertToPdfA3b = request.isConvertToPdfA3b(); @@ -149,6 +152,7 @@ public class AttachmentController { + " Input:PDF Output:ZIP Type:SISO") public ResponseEntity extractAttachments( @ModelAttribute ExtractAttachmentsRequest request) throws IOException { + // Hybrid: PDFBox preserves embedded-file dates/description on ZIP entries. try (PDDocument document = pdfDocumentFactory.load(request, true)) { Optional extracted = pdfAttachmentService.extractAttachments(document); @@ -183,6 +187,7 @@ public class AttachmentController { "This endpoint lists all embedded attachments in a PDF. Input:PDF Output:JSON Type:SISO") public ResponseEntity> listAttachments(@ModelAttribute ListAttachmentsRequest request) throws IOException { + // Hybrid: PDFBox preserves content-type/description/dates that JPDFium's record lacks. try (PDDocument document = pdfDocumentFactory.load(request, true)) { List attachments = pdfAttachmentService.listAttachments(document); @@ -201,6 +206,7 @@ public class AttachmentController { "This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO") public ResponseEntity renameAttachment( @ModelAttribute RenameAttachmentRequest request) throws Exception { + // Hybrid: JPDFium has no rename API; PDFBox edits the embedded-files name tree directly. MultipartFile fileInput = request.getFileInput(); String attachmentName = request.getAttachmentName(); String newName = request.getNewName(); @@ -236,6 +242,7 @@ public class AttachmentController { "This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO") public ResponseEntity deleteAttachment( @ModelAttribute DeleteAttachmentRequest request) throws Exception { + // JPDFium: locate by name then delete via FPDFDoc_DeleteAttachment(index). MultipartFile fileInput = request.getFileInput(); String attachmentName = request.getAttachmentName(); @@ -244,15 +251,50 @@ public class AttachmentController { "error.attachmentNameRequired", "Attachment name cannot be null or empty"); } - try (PDDocument document = pdfDocumentFactory.load(request, false)) { - pdfAttachmentService.deleteAttachment(document, attachmentName); + TempFile inputTemp = new TempFile(tempFileManager, ".pdf"); + TempFile tempOut = null; + try { + fileInput.transferTo(inputTemp.getFile()); - return WebResponseUtils.pdfDocToWebResponse( - document, + try (PdfDocument doc = PdfDocument.open(inputTemp.getPath())) { + int matchIndex = -1; + List existing = doc.attachments(); + for (Attachment att : existing) { + if (attachmentName.equals(att.name())) { + matchIndex = att.index(); + break; + } + } + + if (matchIndex < 0) { + log.warn("Attachment '{}' not found for deletion", attachmentName); + throw ExceptionUtils.createIllegalArgumentException( + "error.attachmentNotFound", + "Attachment ''{0}'' not found for deletion", + attachmentName); + } + + if (!doc.deleteAttachment(matchIndex)) { + throw new IOException("Failed to delete attachment '" + attachmentName + "'"); + } + + tempOut = tempFileManager.createManagedTempFile(".pdf"); + doc.save(tempOut.getPath()); + log.info("Deleted attachment: '{}'", attachmentName); + } + + return WebResponseUtils.pdfFileToWebResponse( + tempOut, GeneralUtils.generateFilename( Filenames.toSimpleFileName(fileInput.getOriginalFilename()), - "_attachment_deleted.pdf"), - tempFileManager); + "_attachment_deleted.pdf")); + } catch (RuntimeException | IOException e) { + if (tempOut != null) { + tempOut.close(); + } + throw e; + } finally { + inputTemp.close(); } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java index ce4ca94239..59f2e23fb0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java @@ -1,17 +1,8 @@ package stirling.software.SPDF.controller.api.misc; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.lang.foreign.MemorySegment; -import org.apache.pdfbox.cos.COSDictionary; -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.graphics.PDXObject; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; -import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -27,19 +18,20 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; -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.PdfPageEditor; @GeneralApi @Slf4j @RequiredArgsConstructor public class RemoveImagesController { - private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; @AutoJobPostMapping( @@ -56,93 +48,66 @@ public class RemoveImagesController { MultipartFile inputFile = request.getFileInput(); - try (PDDocument pdfDoc = pdfDocumentFactory.load(request)) { + TempFile inputTemp = new TempFile(tempFileManager, ".pdf"); + try { + inputFile.transferTo(inputTemp.getFile()); - int totalPages = pdfDoc.getNumberOfPages(); int imagesRemoved = 0; + int totalPages; - for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { - PDPage currentPage = pdfDoc.getPage(pageIndex); - imagesRemoved += removeImagesFromPage(currentPage); + try (PdfDocument doc = PdfDocument.open(inputTemp.getPath())) { + totalPages = doc.pageCount(); + for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { + try (PdfPage page = doc.page(pageIndex)) { + imagesRemoved += removeImagesFromPage(page); + } + } + + TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); + try { + doc.save(tempOut.getPath()); + } catch (RuntimeException e) { + tempOut.close(); + throw e; + } + + log.info("Removed {} images from PDF with {} pages", imagesRemoved, totalPages); + + return WebResponseUtils.pdfFileToWebResponse( + tempOut, + GeneralUtils.generateFilename( + inputFile.getOriginalFilename(), "_images_removed.pdf")); } - - log.info("Removed {} images from PDF with {} pages", imagesRemoved, totalPages); - - TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); - try { - pdfDoc.save(tempOut.getFile()); - } catch (IOException e) { - tempOut.close(); - throw e; - } - - return WebResponseUtils.pdfFileToWebResponse( - tempOut, - GeneralUtils.generateFilename( - inputFile.getOriginalFilename(), "_images_removed.pdf")); - + } catch (RuntimeException e) { + throw ExceptionUtils.handlePdfException(new IOException(e), "during image removal"); } catch (IOException e) { throw ExceptionUtils.handlePdfException(e, "during image removal"); + } finally { + inputTemp.close(); } } - private int removeImagesFromPage(PDPage page) throws IOException { - int imagesRemoved = 0; - - PDResources resources = page.getResources(); - if (resources == null) { - return imagesRemoved; - } - - imagesRemoved += removeImagesFromResources(resources); - return imagesRemoved; - } - - private int removeImagesFromFormXObject(PDFormXObject formXObject) throws IOException { - PDResources resources = formXObject.getResources(); - if (resources == null) { - return 0; - } - - return removeImagesFromResources(resources); - } - - private int removeImagesFromResources(PDResources resources) throws IOException { - if (resources == null) { - return 0; - } - - COSDictionary xObjects = resources.getCOSObject().getCOSDictionary(COSName.XOBJECT); - if (xObjects == null) { - return 0; - } - - int imagesRemoved = 0; - // Create snapshot to safely iterate while removing - List names = new ArrayList<>(xObjects.keySet()); - - for (COSName name : names) { - try { - PDXObject xObject = resources.getXObject(name); - if (xObject == null) { - continue; + // Remove all IMAGE-typed page objects from a single page using JPDFium's editor APIs. + private int removeImagesFromPage(PdfPage page) { + MemorySegment pageHandle = page.rawHandle(); + int removed = 0; + // Iterate in reverse so index shifts don't skip elements after removal. + int count = PdfPageEditor.countObjects(pageHandle); + for (int i = count - 1; i >= 0; i--) { + MemorySegment obj = PdfPageEditor.getObject(pageHandle, i); + if (obj == null) { + continue; + } + int type = PdfPageEditor.getObjectType(obj); + if (type == PdfPageEditor.PAGEOBJ_IMAGE) { + if (PdfPageEditor.removeObject(pageHandle, obj)) { + removed++; } - - // Remove direct images - if (xObject instanceof PDImageXObject) { - xObjects.removeItem(name); - imagesRemoved++; - log.debug("Removed image: {}", name.getName()); - } - // Recursively process nested form XObjects - else if (xObject instanceof PDFormXObject form) { - imagesRemoved += removeImagesFromResources(form.getResources()); - } - } catch (IOException e) { - log.warn("Error processing XObject {}: {}", name.getName(), e.getMessage()); } } - - return imagesRemoved; + if (removed > 0) { + PdfPageEditor.generateContent(pageHandle); + } + return removed; } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java index 97b7e35414..cfedca8241 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java @@ -1,18 +1,31 @@ package stirling.software.SPDF.controller.api.misc; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary; +import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; +import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; import org.junit.jupiter.api.BeforeEach; 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.MockedStatic; @@ -26,6 +39,7 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import stirling.software.SPDF.model.api.misc.AddAttachmentRequest; +import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest; import stirling.software.SPDF.service.AttachmentServiceInterface; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFile; @@ -38,13 +52,7 @@ class AttachmentControllerTest { return ResponseEntity.ok(new ByteArrayResource(bytes)); } - private static byte[] drainBody(ResponseEntity response) throws java.io.IOException { - java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - try (java.io.InputStream __in = response.getBody().getInputStream()) { - __in.transferTo(baos); - } - return baos.toByteArray(); - } + @TempDir Path tempDir; @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @@ -62,12 +70,19 @@ class AttachmentControllerTest { @BeforeEach void setUp() throws Exception { + lenient() + .when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> + Files.createTempFile(tempDir, "input", inv.getArgument(0)) + .toFile()); lenient() .when(tempFileManager.createManagedTempFile(anyString())) .thenAnswer( inv -> { File f = - Files.createTempFile("test", inv.getArgument(0)) + Files.createTempFile( + tempDir, "managed", inv.getArgument(0)) .toFile(); TempFile tf = mock(TempFile.class); lenient().when(tf.getFile()).thenReturn(f); @@ -188,4 +203,93 @@ class AttachmentControllerTest { assertThrows(IOException.class, () -> attachmentController.addAttachments(request)); verify(pdfAttachmentService).addAttachment(mockDocument, attachments); } + + // Build a PDF with one embedded attachment using PDFBox so JPDFium can read it back. + private byte[] buildPdfWithAttachment(String attachmentName, byte[] attachmentBytes) + throws IOException { + Path path = tempDir.resolve("with-attachment.pdf"); + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.LETTER)); + + PDComplexFileSpecification spec = new PDComplexFileSpecification(); + spec.setFile(attachmentName); + spec.setFileUnicode(attachmentName); + + PDEmbeddedFile embedded = + new PDEmbeddedFile(doc, new ByteArrayInputStream(attachmentBytes)); + embedded.setSize(attachmentBytes.length); + spec.setEmbeddedFile(embedded); + spec.setEmbeddedFileUnicode(embedded); + + PDDocumentCatalog catalog = doc.getDocumentCatalog(); + PDDocumentNameDictionary names = new PDDocumentNameDictionary(catalog); + PDEmbeddedFilesNameTreeNode tree = new PDEmbeddedFilesNameTreeNode(); + tree.setNames(java.util.Map.of(attachmentName, spec)); + names.setEmbeddedFiles(tree); + catalog.setNames(names); + + doc.save(path.toFile()); + } + return Files.readAllBytes(path); + } + + private int countAttachmentsInPdf(byte[] pdfBytes) throws IOException { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDDocumentCatalog catalog = doc.getDocumentCatalog(); + if (catalog == null || catalog.getNames() == null) { + return 0; + } + PDEmbeddedFilesNameTreeNode tree = catalog.getNames().getEmbeddedFiles(); + if (tree == null || tree.getNames() == null) { + return 0; + } + return tree.getNames().size(); + } + } + + @Test + void deleteAttachment_removesNamedAttachment() throws Exception { + byte[] pdfBytes = buildPdfWithAttachment("to_delete.txt", "payload".getBytes()); + assertEquals(1, countAttachmentsInPdf(pdfBytes)); + + MockMultipartFile input = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setFileInput(input); + req.setAttachmentName("to_delete.txt"); + + ResponseEntity response = attachmentController.deleteAttachment(req); + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + try (InputStream in = response.getBody().getInputStream()) { + byte[] outBytes = in.readAllBytes(); + assertThat(countAttachmentsInPdf(outBytes)).isZero(); + } + } + + @Test + void deleteAttachment_missingName_throws() { + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setFileInput(pdfFile); + req.setAttachmentName(""); + + assertThrows( + IllegalArgumentException.class, () -> attachmentController.deleteAttachment(req)); + } + + @Test + void deleteAttachment_notFound_throws() throws Exception { + byte[] pdfBytes = buildPdfWithAttachment("present.txt", "stuff".getBytes()); + MockMultipartFile input = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setFileInput(input); + req.setAttachmentName("does_not_exist.txt"); + + assertThrows( + IllegalArgumentException.class, () -> attachmentController.deleteAttachment(req)); + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java new file mode 100644 index 0000000000..6fe1084b06 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java @@ -0,0 +1,193 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +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.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +@ExtendWith(MockitoExtension.class) +class RemoveImagesControllerTest { + + @TempDir Path tempDir; + + @Mock private TempFileManager tempFileManager; + + @InjectMocks private RemoveImagesController controller; + + private void wireTempFileManager() throws IOException { + lenient() + .when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> Files.createTempFile(tempDir, "test", inv.getArgument(0)).toFile()); + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile(tempDir, "managed", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + } + + private MockMultipartFile createPdfWithImages(int imagesPerPage, int pages) throws IOException { + Path path = tempDir.resolve("with-images.pdf"); + try (PDDocument doc = new PDDocument()) { + for (int p = 0; p < pages; p++) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + for (int i = 0; i < imagesPerPage; i++) { + BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + PDImageXObject pdImage = JPEGFactory.createFromImage(doc, img); + cs.drawImage(pdImage, 50f + i * 30, 600f, 25f, 25f); + } + } + } + doc.save(path.toFile()); + } + return new MockMultipartFile( + "fileInput", + "with-images.pdf", + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(path)); + } + + private MockMultipartFile createTextOnlyPdf(int pages) throws IOException { + Path path = tempDir.resolve("text-only.pdf"); + try (PDDocument doc = new PDDocument()) { + for (int p = 0; p < pages; p++) { + doc.addPage(new PDPage(PDRectangle.LETTER)); + } + doc.save(path.toFile()); + } + return new MockMultipartFile( + "fileInput", + "text-only.pdf", + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(path)); + } + + // PDFBox oracle: walk every page (and nested forms) and count remaining image XObjects. + private int countImagesInDocument(byte[] pdfBytes) throws IOException { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + int total = 0; + for (PDPage page : doc.getPages()) { + total += countImagesInResources(page.getResources()); + } + return total; + } + } + + private int countImagesInResources(PDResources resources) throws IOException { + if (resources == null) { + return 0; + } + int count = 0; + for (COSName name : resources.getXObjectNames()) { + PDXObject xo = resources.getXObject(name); + if (xo instanceof PDImageXObject) { + count++; + } else if (xo instanceof PDFormXObject form) { + count += countImagesInResources(form.getResources()); + } + } + return count; + } + + @Test + void removeImages_withImages_strippedOnRender() throws IOException { + wireTempFileManager(); + MockMultipartFile file = createPdfWithImages(2, 2); + PDFFile request = new PDFFile(); + request.setFileInput(file); + + int before = countImagesInDocument(file.getBytes()); + assertThat(before).isEqualTo(4); + + ResponseEntity response = controller.removeImages(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + try (InputStream in = response.getBody().getInputStream()) { + byte[] outBytes = in.readAllBytes(); + try (PDDocument out = Loader.loadPDF(outBytes)) { + assertThat(out.getNumberOfPages()).isEqualTo(2); + for (PDPage page : out.getPages()) { + int residualOnPage = countImagesInResources(page.getResources()); + assertThat(residualOnPage).isZero(); + } + } + } + } + + @Test + void removeImages_textOnlyPdf_returnsValidPdf() throws IOException { + wireTempFileManager(); + MockMultipartFile file = createTextOnlyPdf(3); + PDFFile request = new PDFFile(); + request.setFileInput(file); + + ResponseEntity response = controller.removeImages(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + try (InputStream in = response.getBody().getInputStream()) { + byte[] outBytes = in.readAllBytes(); + try (PDDocument out = Loader.loadPDF(outBytes)) { + assertThat(out.getNumberOfPages()).isEqualTo(3); + } + } + } + + @Test + void removeImages_corruptedInput_throws() throws IOException { + wireTempFileManager(); + MockMultipartFile bad = + new MockMultipartFile( + "fileInput", + "broken.pdf", + MediaType.APPLICATION_PDF_VALUE, + "not a real pdf".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(bad); + + assertThatThrownBy(() -> controller.removeImages(request)).isInstanceOf(IOException.class); + } +}