impl migration to pdfium for attachments and remove images

This commit is contained in:
Anthony Stirling
2026-05-22 09:43:00 +01:00
parent 2f1fe2c80c
commit d76f0ea91d
4 changed files with 406 additions and 102 deletions
@@ -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();
}
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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);
}
}