impl migration to pdfium for attachments and remove images
This commit is contained in:
+48
-6
@@ -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<Resource> addAttachments(@ModelAttribute AddAttachmentRequest request)
|
||||
throws Exception {
|
||||
// Hybrid: JPDFium add lacks viewer prefs, content-type and dates; PDFBox preserves them.
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
List<MultipartFile> attachments = request.getAttachments();
|
||||
boolean convertToPdfA3b = request.isConvertToPdfA3b();
|
||||
@@ -149,6 +152,7 @@ public class AttachmentController {
|
||||
+ " Input:PDF Output:ZIP Type:SISO")
|
||||
public ResponseEntity<Resource> extractAttachments(
|
||||
@ModelAttribute ExtractAttachmentsRequest request) throws IOException {
|
||||
// Hybrid: PDFBox preserves embedded-file dates/description on ZIP entries.
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
|
||||
Optional<byte[]> 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<List<stirling.software.SPDF.model.api.misc.AttachmentInfo>>
|
||||
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<stirling.software.SPDF.model.api.misc.AttachmentInfo> 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<Resource> 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<Resource> 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<Attachment> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-88
@@ -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<COSName> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+112
-8
@@ -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<Resource> 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.<String>getArgument(0))
|
||||
.toFile());
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("test", inv.<String>getArgument(0))
|
||||
Files.createTempFile(
|
||||
tempDir, "managed", inv.<String>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<Resource> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+193
@@ -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<Resource> 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<Resource> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user