mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-01-14 20:11:17 +01:00
[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:
parent
1b0a1e938e
commit
54cd804319
File diff suppressed because it is too large
Load Diff
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user