[V2] feat(attachments): add PDF/A-3b conversion, attachment listing, renaming, and deletion (#5304)

# Description of Changes

This pull request introduces major improvements to the PDF attachment
API, adding new endpoints for listing, renaming, and deleting
attachments in PDFs, as well as improving error handling and content
negotiation. It also adds support for converting PDFs to PDF/A-3b format
when adding attachments and introduces stricter validation for
attachment uploads. The exception handling is improved to ensure
consistent JSON error responses, even when the client requests a PDF.

**API Feature Additions:**

* Added new endpoints in `AttachmentController` for listing
(`/list-attachments`), renaming (`/rename-attachment`), and deleting
(`/delete-attachment`) PDF attachments, with corresponding request and
response models: `ListAttachmentsRequest`, `RenameAttachmentRequest`,
`DeleteAttachmentRequest`, and `AttachmentInfo`.

* Enhanced the `/add-attachments` endpoint to optionally convert the
resulting PDF to PDF/A-3b format, controlled by a new `convertToPdfA3b`
flag in `AddAttachmentRequest`.

**Validation and Robustness:**

* Introduced strict validation for attachment uploads, enforcing
non-empty attachments, a maximum size per attachment (50 MB), and a
total size limit (200 MB).

**Content Negotiation:**

* Updated `WebMvcConfig` to configure content negotiation, allowing both
PDF and JSON responses, and preventing 406 errors when clients request
PDFs but errors must be returned as JSON.


<img width="370" height="997" alt="image"
src="https://github.com/user-attachments/assets/571504d4-e97e-4b30-ae97-3defba217b47"
/>
<img width="1415" height="649" alt="image"
src="https://github.com/user-attachments/assets/bb8863fc-0be8-4bf2-af7d-73a229010f9a"
/>
<img width="1415" height="649" alt="image"
src="https://github.com/user-attachments/assets/68092672-5be5-4ef7-9cbc-1fb008b728e1"
/>
<img width="1415" height="649" alt="image"
src="https://github.com/user-attachments/assets/c4b0eda5-2573-4e38-8284-c077acb83f7f"
/>



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2025-12-24 22:35:36 +01:00 committed by GitHub
parent 1b0a1e938e
commit 54cd804319
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1376 additions and 374 deletions

View File

@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@ -17,8 +18,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
import stirling.software.SPDF.service.AttachmentServiceInterface;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
@ -36,6 +41,8 @@ public class AttachmentController {
private final AttachmentServiceInterface pdfAttachmentService;
private final ConvertPDFToPDFA convertPDFToPDFA;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-attachments")
@StandardPdfResponse
@Operation(
@ -43,19 +50,87 @@ public class AttachmentController {
description =
"This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO")
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
throws IOException {
throws Exception {
MultipartFile fileInput = request.getFileInput();
List<MultipartFile> attachments = request.getAttachments();
boolean convertToPdfA3b = request.isConvertToPdfA3b();
PDDocument document =
pdfAttachmentService.addAttachment(
pdfDocumentFactory.load(fileInput, false), attachments);
validateAttachmentRequest(attachments);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_with_attachments.pdf"));
String originalFileName = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFileName == null || originalFileName.isEmpty()) {
originalFileName = "document";
}
String baseFileName =
originalFileName.contains(".")
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
: originalFileName;
if (convertToPdfA3b) {
byte[] pdfaBytes;
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfaBytes = convertPDFToPDFA.convertPDDocumentToPDFA(document, "pdfa-3b");
}
try (PDDocument pdfaDocument = org.apache.pdfbox.Loader.loadPDF(pdfaBytes)) {
pdfAttachmentService.addAttachment(pdfaDocument, attachments);
convertPDFToPDFA.ensureEmbeddedFileCompliance(pdfaDocument);
ConvertPDFToPDFA.fixType1FontCharSet(pdfaDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
pdfaDocument.save(baos);
byte[] resultBytes = baos.toByteArray();
String outputFilename = baseFileName + "_with_attachments_PDFA-3b.pdf";
return WebResponseUtils.bytesToWebResponse(
resultBytes, outputFilename, MediaType.APPLICATION_PDF);
}
} else {
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.addAttachment(document, attachments);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_with_attachments.pdf"));
}
}
}
private void validateAttachmentRequest(List<MultipartFile> attachments) {
if (attachments == null || attachments.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentsRequired", "At least one attachment is required");
}
final long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB per attachment
final long maxTotalSize = 200L * 1024 * 1024; // 200 MB total
long totalSize = 0;
for (MultipartFile attachment : attachments) {
if (attachment == null || attachment.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentEmpty", "Attachment files cannot be null or empty");
}
if (attachment.getSize() > maxAttachmentSize) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentTooLarge",
"Attachment ''{0}'' exceeds maximum size of {1} bytes",
attachment.getOriginalFilename(),
maxAttachmentSize);
}
totalSize += attachment.getSize();
}
if (totalSize > maxTotalSize) {
throw ExceptionUtils.createIllegalArgumentException(
"error.totalAttachmentsTooLarge",
"Total attachment size {0} exceeds maximum of {1} bytes",
totalSize,
maxTotalSize);
}
}
@AutoJobPostMapping(
@ -88,4 +163,82 @@ public class AttachmentController {
extracted.get(), outputName, MediaType.APPLICATION_OCTET_STREAM);
}
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
@Operation(
summary = "List attachments in PDF",
description =
"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 {
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
List<stirling.software.SPDF.model.api.misc.AttachmentInfo> attachments =
pdfAttachmentService.listAttachments(document);
return ResponseEntity.ok(attachments);
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rename-attachment")
@StandardPdfResponse
@Operation(
summary = "Rename attachment in PDF",
description =
"This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> renameAttachment(@ModelAttribute RenameAttachmentRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
String attachmentName = request.getAttachmentName();
String newName = request.getNewName();
if (attachmentName == null || attachmentName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
}
if (newName == null || newName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.newNameRequired", "New attachment name cannot be null or empty");
}
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.renameAttachment(document, attachmentName, newName);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_attachment_renamed.pdf"));
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/delete-attachment")
@StandardPdfResponse
@Operation(
summary = "Delete attachment from PDF",
description =
"This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> deleteAttachment(@ModelAttribute DeleteAttachmentRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
String attachmentName = request.getAttachmentName();
if (attachmentName == null || attachmentName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
}
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.deleteAttachment(document, attachmentName);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_attachment_deleted.pdf"));
}
}
}

View File

@ -12,6 +12,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
@ -23,6 +24,7 @@ import org.springframework.web.multipart.support.MissingServletRequestPartExcept
import org.springframework.web.servlet.NoHandlerFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -808,6 +810,56 @@ public class GlobalExceptionHandler {
.body(problemDetail);
}
/**
* Handle 406 Not Acceptable errors when error responses cannot match client Accept header.
*
* <p>When thrown: When the client sends Accept: application/pdf but the server needs to return
* a JSON error response (e.g., when an attachment is not found).
*
* <p>This handler writes directly to HttpServletResponse to bypass Spring's content negotiation
* and ensure error responses are always delivered as JSON.
*
* @param ex the HttpMediaTypeNotAcceptableException
* @param request the HTTP servlet request
* @param response the HTTP servlet response
*/
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public void handleMediaTypeNotAcceptable(
HttpMediaTypeNotAcceptableException ex,
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
log.warn(
"Media type not acceptable at {}: client accepts {}, server supports {}",
request.getRequestURI(),
request.getHeader("Accept"),
ex.getSupportedMediaTypes());
// Write JSON error response directly, bypassing content negotiation
response.setStatus(HttpStatus.NOT_ACCEPTABLE.value());
response.setContentType("application/problem+json");
response.setCharacterEncoding("UTF-8");
String errorJson =
String.format(
"""
{
"type": "about:blank",
"title": "Not Acceptable",
"status": 406,
"detail": "The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.",
"instance": "%s",
"timestamp": "%s",
"hints": ["Error responses are always returned as application/json or application/problem+json", "Set Accept header to include application/json for proper error handling"]
}
""",
request.getRequestURI(), Instant.now().toString());
response.getWriter().write(errorJson);
response.getWriter().flush();
}
// ===========================================================================================
// JAVA STANDARD EXCEPTIONS
// ===========================================================================================
@ -963,9 +1015,8 @@ public class GlobalExceptionHandler {
// Check if this RuntimeException wraps a typed exception from job execution
Throwable cause = ex.getCause();
if (cause instanceof BaseAppException) {
if (cause instanceof BaseAppException appEx) {
// Delegate to specific BaseAppException handlers
BaseAppException appEx = (BaseAppException) cause;
if (appEx instanceof PdfPasswordException) {
return handlePdfPassword((PdfPasswordException) appEx, request);
} else if (appEx instanceof PdfCorruptedException
@ -979,9 +1030,8 @@ public class GlobalExceptionHandler {
} else {
return handleBaseApp(appEx, request);
}
} else if (cause instanceof BaseValidationException) {
} else if (cause instanceof BaseValidationException valEx) {
// Delegate to validation exception handlers
BaseValidationException valEx = (BaseValidationException) cause;
if (valEx instanceof CbrFormatException
|| valEx instanceof CbzFormatException
|| valEx instanceof EmlFormatException) {
@ -992,6 +1042,9 @@ public class GlobalExceptionHandler {
} else if (cause instanceof IOException) {
// Unwrap and handle IOException (may contain PDF-specific errors)
return handleIOException((IOException) cause, request);
} else if (cause instanceof IllegalArgumentException) {
// Unwrap and handle IllegalArgumentException (business logic validation errors)
return handleIllegalArgument((IllegalArgumentException) cause, request);
}
// Not a wrapped exception - treat as unexpected error

View File

@ -20,4 +20,10 @@ public class AddAttachmentRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private List<MultipartFile> attachments;
@Schema(
description = "Convert the resulting PDF to PDF/A-3b format after adding attachments",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean convertToPdfA3b = false;
}

View File

@ -0,0 +1,17 @@
package stirling.software.SPDF.model.api.misc;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AttachmentInfo {
private String filename;
private Long size;
private String contentType;
private String description;
private String creationDate;
private String modificationDate;
}

View File

@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api.misc;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class DeleteAttachmentRequest extends PDFFile {
@Schema(
description = "The name of the attachment to delete",
requiredMode = Schema.RequiredMode.REQUIRED)
private String attachmentName;
}

View File

@ -0,0 +1,10 @@
package stirling.software.SPDF.model.api.misc;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ListAttachmentsRequest extends PDFFile {}

View File

@ -0,0 +1,23 @@
package stirling.software.SPDF.model.api.misc;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class RenameAttachmentRequest extends PDFFile {
@Schema(
description = "The current name of the attachment to rename",
requiredMode = Schema.RequiredMode.REQUIRED)
private String attachmentName;
@Schema(
description = "The new name for the attachment",
requiredMode = Schema.RequiredMode.REQUIRED)
private String newName;
}

View File

@ -8,6 +8,7 @@ import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
@ -36,6 +37,9 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@Service
public class AttachmentService implements AttachmentServiceInterface {
@ -216,6 +220,142 @@ public class AttachmentService implements AttachmentServiceInterface {
}
}
@Override
public List<AttachmentInfo> listAttachments(PDDocument document) throws IOException {
List<AttachmentInfo> attachments = new ArrayList<>();
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
return attachments;
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
return attachments;
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
return attachments;
}
Map<String, PDComplexFileSpecification> embeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, embeddedFiles);
for (Map.Entry<String, PDComplexFileSpecification> entry : embeddedFiles.entrySet()) {
PDComplexFileSpecification fileSpecification = entry.getValue();
PDEmbeddedFile embeddedFile = getEmbeddedFile(fileSpecification);
if (embeddedFile != null) {
String filename = determineFilename(entry.getKey(), fileSpecification);
String description = fileSpecification.getFileDescription();
String contentType = embeddedFile.getSubtype();
Long size = (long) embeddedFile.getSize();
String creationDate = null;
if (embeddedFile.getCreationDate() != null) {
creationDate = embeddedFile.getCreationDate().getTime().toString();
}
String modificationDate = null;
if (embeddedFile.getModDate() != null) {
modificationDate = embeddedFile.getModDate().getTime().toString();
}
AttachmentInfo attachmentInfo =
new AttachmentInfo(
filename,
size,
contentType,
description,
creationDate,
modificationDate);
attachments.add(attachmentInfo);
}
}
return attachments;
}
@Override
public PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
throws IOException {
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
PDComplexFileSpecification fileToRename = null;
String keyToRename = null;
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
String currentName = determineFilename(entry.getKey(), entry.getValue());
if (currentName.equals(attachmentName)) {
fileToRename = entry.getValue();
keyToRename = entry.getKey();
break;
}
}
if (fileToRename == null || keyToRename == null) {
log.warn("Attachment '{}' not found for renaming", attachmentName);
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNotFound",
"Attachment ''{0}'' not found for renaming",
attachmentName);
}
fileToRename.setFile(newName);
fileToRename.setFileUnicode(newName);
allEmbeddedFiles.remove(keyToRename);
allEmbeddedFiles.put(newName, fileToRename);
embeddedFilesTree.setKids(null);
embeddedFilesTree.setNames(allEmbeddedFiles);
log.info("Renamed attachment from '{}' to '{}'", attachmentName, newName);
return document;
}
@Override
public PDDocument deleteAttachment(PDDocument document, String attachmentName)
throws IOException {
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
String keyToRemove = null;
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
String currentName = determineFilename(entry.getKey(), entry.getValue());
if (currentName.equals(attachmentName)) {
keyToRemove = entry.getKey();
break;
}
}
if (keyToRemove == null) {
log.warn("Attachment '{}' not found for deletion", attachmentName);
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNotFound",
"Attachment ''{0}'' not found for deletion",
attachmentName);
}
allEmbeddedFiles.remove(keyToRemove);
embeddedFilesTree.setKids(null);
embeddedFilesTree.setNames(allEmbeddedFiles);
log.info("Deleted attachment: '{}'", attachmentName);
return document;
}
private String sanitizeFilename(String candidate) {
String sanitized = Filenames.toSimpleFileName(candidate);
if (StringUtils.isBlank(sanitized)) {

View File

@ -7,10 +7,19 @@ import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
public interface AttachmentServiceInterface {
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
throws IOException;
Optional<byte[]> extractAttachments(PDDocument document) throws IOException;
List<AttachmentInfo> listAttachments(PDDocument document) throws IOException;
PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
throws IOException;
PDDocument deleteAttachment(PDDocument document, String attachmentName) throws IOException;
}

View File

@ -67,16 +67,16 @@ class AttachmentControllerTest {
}
@Test
void addAttachments_Success() throws IOException {
void addAttachments_Success() throws Exception {
List<MultipartFile> attachments = List.of(attachment1, attachment2);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(modifiedMockDocument);
.thenReturn(mockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
@ -84,8 +84,7 @@ class AttachmentControllerTest {
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
eq(mockDocument), eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@ -93,22 +92,22 @@ class AttachmentControllerTest {
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(pdfDocumentFactory).load(pdfFile, false);
verify(pdfDocumentFactory).load(request, false);
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
}
}
@Test
void addAttachments_SingleAttachment() throws IOException {
void addAttachments_SingleAttachment() throws Exception {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(modifiedMockDocument);
.thenReturn(mockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
@ -116,8 +115,7 @@ class AttachmentControllerTest {
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
eq(mockDocument), eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@ -125,33 +123,33 @@ class AttachmentControllerTest {
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(pdfDocumentFactory).load(pdfFile, false);
verify(pdfDocumentFactory).load(request, false);
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
}
}
@Test
void addAttachments_IOExceptionFromPDFLoad() throws IOException {
void addAttachments_IOExceptionFromPDFLoad() throws Exception {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
IOException ioException = new IOException("Failed to load PDF");
when(pdfDocumentFactory.load(pdfFile, false)).thenThrow(ioException);
when(pdfDocumentFactory.load(request, false)).thenThrow(ioException);
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));
verify(pdfDocumentFactory).load(pdfFile, false);
verify(pdfDocumentFactory).load(request, false);
verifyNoInteractions(pdfAttachmentService);
}
@Test
void addAttachments_IOExceptionFromAttachmentService() throws IOException {
void addAttachments_IOExceptionFromAttachmentService() throws Exception {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
IOException ioException = new IOException("Failed to add attachment");
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenThrow(ioException);
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));

View File

@ -1394,6 +1394,11 @@ header = "Add Attachments"
add = "Add Attachment"
remove = "Remove Attachment"
embed = "Embed Attachment"
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
convertToPdfA3bTooltip = "PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files."
convertToPdfA3bTooltipHeader = "About PDF/A-3b Conversion"
convertToPdfA3bTooltipTitle = "What it does"
submit = "Add Attachments"
[watermark]

View File

@ -1,13 +1,14 @@
/**
* AddAttachmentsSettings - Shared settings component for both tool UI and automation
*
* Allows selecting files to attach to PDFs.
* Allows selecting files to attach to PDFs with optional PDF/A-3b conversion support.
*/
import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core";
import { Stack, Text, Group, ActionIcon, ScrollArea, Button, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
interface AddAttachmentsSettingsProps {
parameters: AddAttachmentsParameters;
@ -103,6 +104,40 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
</ScrollArea.Autosize>
</Stack>
)}
{/* PDF/A-3b conversion option with informative tooltip */}
<Group gap="xs" align="flex-start">
<Checkbox
label={
<Group gap={4}>
<Text size="sm">{t("attachments.convertToPdfA3b", "Convert to PDF/A-3b")}</Text>
<Tooltip
header={{
title: t("attachments.convertToPdfA3bTooltipHeader", "About PDF/A-3b Conversion")
}}
tips={[
{
title: t("attachments.convertToPdfA3bTooltipTitle", "What it does"),
description: t(
"attachments.convertToPdfA3bTooltip",
"PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files."
)
}
]}
sidebarTooltip={true}
pinOnClick={true}
>
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)', cursor: 'help' }} />
</Tooltip>
</Group>
}
description={t("attachments.convertToPdfA3bDescription", "Creates an archival PDF with embedded attachments")}
checked={parameters.convertToPdfA3b}
onChange={(event) => onParameterChange('convertToPdfA3b', event.currentTarget.checked)}
disabled={disabled}
styles={{ root: { flex: 1 } }}
/>
</Group>
</Stack>
);
};

View File

@ -16,6 +16,8 @@ const buildFormData = (parameters: AddAttachmentsParameters, file: File): FormDa
if (attachment) formData.append("attachments", attachment);
});
formData.append("convertToPdfA3b", String(parameters.convertToPdfA3b));
return formData;
};

View File

@ -2,10 +2,12 @@ import { useState } from 'react';
export interface AddAttachmentsParameters {
attachments: File[];
convertToPdfA3b: boolean;
}
const defaultParameters: AddAttachmentsParameters = {
attachments: []
attachments: [],
convertToPdfA3b: false
};
export const useAddAttachmentsParameters = () => {
@ -33,3 +35,5 @@ export const useAddAttachmentsParameters = () => {
validateParameters
};
};
export const DEFAULT_ADD_ATTACHMENTS_PARAMETERS: AddAttachmentsParameters = defaultParameters;