Add Attachments Feature (#3781)

# Description of Changes
Added a new feature to add attachments to a PDF document. 

### Added:
- `AttachmentController`: Endpoint for adding attachments at
`/add-attachments` with parameters `fileInput` for the PDF and
`attachments` as a list of files to attach
- `AttachmentServiceInterface`
- `AttachmentService`: Handles the logic of adding attachments to the
PDF
- `AttachmentUtils`: to handle setting the catalog viewer preferences in
the viewer
- Add Attachments page
- Tests for new feature

### Changes:
- `EmlToPdf`: Moved setting of viewer preferences to `AttachmentUtils`
- `EndpointConfiguration: Included '/add-attachments'
- Updated language files with attachments copy
- General clean up

Closes #1259 

---

## 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/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [x] 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)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

![348abcd235de976c347b8cd2c7979318](https://github.com/user-attachments/assets/56c3c992-7ab2-4723-8ddb-57038b557287)
![Screenshot 2025-06-20 at 13 52
53](https://github.com/user-attachments/assets/dc7a69c3-a85d-41c4-bac6-6a5d901459b9)
![Screenshot 2025-06-20 at 13 57
55](https://github.com/user-attachments/assets/8025a659-db7e-4441-946c-ce10414974ce)
![Screenshot 2025-06-20 at 13 58
20](https://github.com/user-attachments/assets/ae8d464e-1c95-4db1-92e6-1cf894fa4e83)

- [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/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Dario Ghunney Ware
2025-06-24 23:52:17 +01:00
committed by GitHub
parent 8e8f0492c4
commit 32aa568196
28 changed files with 624 additions and 76 deletions

View File

@@ -0,0 +1,50 @@
package stirling.software.common.util;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PageMode;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class AttachmentUtils {
/**
* Sets the PDF catalog viewer preferences to display attachments in the viewer.
*
* @param document The <code>PDDocument</code> to modify.
* @param pageMode The <code>PageMode</code> to set for the PDF viewer. <code>PageMode</code>
* values: <code>UseNone</code>, <code>UseOutlines</code>, <code>UseThumbs</code>, <code>
* FullScreen</code>, <code>UseOC</code>, <code>UseAttachments</code>.
*/
public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) {
try {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null) {
COSDictionary catalogDict = catalog.getCOSObject();
catalog.setPageMode(pageMode);
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
if (viewerPrefs == null) {
viewerPrefs = new COSDictionary();
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
}
viewerPrefs.setName(
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
log.error("Failed to set catalog viewer preferences for attachments", e);
}
}
}

View File

@@ -1,5 +1,7 @@
package stirling.software.common.util;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -20,13 +22,11 @@ import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
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.PageMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
@@ -42,10 +42,13 @@ import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
@UtilityClass
public class EmlToPdf {
private static final class StyleConstants {
// Font and layout constants
static final int DEFAULT_FONT_SIZE = 12;
@@ -194,8 +197,7 @@ public class EmlToPdf {
boolean disableSanitize)
throws IOException, InterruptedException {
stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest =
createHtmlRequest(request);
HTMLToPdfRequest htmlRequest = createHtmlRequest(request);
try {
return FileToPdf.convertHtmlToPdf(
@@ -879,33 +881,33 @@ public class EmlToPdf {
Class<?> messageClass = message.getClass();
// Extract headers via reflection
java.lang.reflect.Method getSubject = messageClass.getMethod("getSubject");
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
java.lang.reflect.Method getFrom = messageClass.getMethod("getFrom");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(
fromAddresses != null && fromAddresses.length > 0
? safeMimeDecode(fromAddresses[0].toString())
: "");
java.lang.reflect.Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(
recipients != null && recipients.length > 0
? safeMimeDecode(recipients[0].toString())
: "");
java.lang.reflect.Method getSentDate = messageClass.getMethod("getSentDate");
Method getSentDate = messageClass.getMethod("getSentDate");
content.setDate((Date) getSentDate.invoke(message));
// Extract content
java.lang.reflect.Method getContent = messageClass.getMethod("getContent");
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
if (messageContent instanceof String stringContent) {
java.lang.reflect.Method getContentType = messageClass.getMethod("getContentType");
Method getContentType = messageClass.getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null && contentType.toLowerCase().contains("text/html")) {
content.setHtmlBody(stringContent);
@@ -944,11 +946,10 @@ public class EmlToPdf {
}
Class<?> multipartClass = multipart.getClass();
java.lang.reflect.Method getCount = multipartClass.getMethod("getCount");
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
java.lang.reflect.Method getBodyPart =
multipartClass.getMethod("getBodyPart", int.class);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
@@ -969,12 +970,12 @@ public class EmlToPdf {
}
Class<?> partClass = part.getClass();
java.lang.reflect.Method isMimeType = partClass.getMethod("isMimeType", String.class);
java.lang.reflect.Method getContent = partClass.getMethod("getContent");
java.lang.reflect.Method getDisposition = partClass.getMethod("getDisposition");
java.lang.reflect.Method getFileName = partClass.getMethod("getFileName");
java.lang.reflect.Method getContentType = partClass.getMethod("getContentType");
java.lang.reflect.Method getHeader = partClass.getMethod("getHeader", String.class);
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
@@ -1181,7 +1182,7 @@ public class EmlToPdf {
private static byte[] attachFilesToPdf(
byte[] pdfBytes,
List<EmailAttachment> attachments,
stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory)
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
@@ -1239,15 +1240,13 @@ public class EmlToPdf {
document, new ByteArrayInputStream(attachment.getData()));
embeddedFile.setSize(attachment.getData().length);
embeddedFile.setCreationDate(new GregorianCalendar());
if (attachment.getContentType() != null) {
embeddedFile.setSubtype(attachment.getContentType());
}
// Create file specification
PDComplexFileSpecification fileSpec = new PDComplexFileSpecification();
fileSpec.setFile(uniqueFilename);
fileSpec.setEmbeddedFile(embeddedFile);
if (attachment.getContentType() != null) {
embeddedFile.setSubtype(attachment.getContentType());
fileSpec.setFileDescription("Email attachment: " + uniqueFilename);
}
@@ -1269,7 +1268,7 @@ public class EmlToPdf {
efTree.setNames(efMap);
// Set catalog viewer preferences to automatically show attachments pane
setCatalogViewerPreferences(document);
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
}
// Add attachment annotations to the first page for each embedded file
@@ -1423,41 +1422,7 @@ public class EmlToPdf {
}
}
private static void setCatalogViewerPreferences(PDDocument document) {
try {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null) {
// Get the catalog's COS dictionary to work with low-level PDF objects
COSDictionary catalogDict = catalog.getCOSObject();
// Set PageMode to UseAttachments - this is the standard PDF specification approach
// PageMode values: UseNone, UseOutlines, UseThumbs, FullScreen, UseOC,
// UseAttachments
catalogDict.setName(COSName.PAGE_MODE, "UseAttachments");
// Also set viewer preferences for better attachment viewing experience
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
if (viewerPrefs == null) {
viewerPrefs = new COSDictionary();
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
}
// Set NonFullScreenPageMode to UseAttachments as fallback for viewers that support
// it
viewerPrefs.setName(COSName.getPDFName("NonFullScreenPageMode"), "UseAttachments");
// Additional viewer preferences that may help with attachment display
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
// Log warning but don't fail the entire operation for viewer preferences
log.warn("Failed to set catalog viewer preferences for attachments", e);
}
}
// MIME header decoding functionality for RFC 2047 encoded headers - moved to constants
private static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {

View File

@@ -16,12 +16,12 @@ import io.github.pixee.security.Filenames;
public class WebResponseUtils {
public static ResponseEntity<byte[]> boasToWebResponse(
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName);
}
public static ResponseEntity<byte[]> boasToWebResponse(
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName, MediaType mediaType) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName, mediaType);
}
@@ -44,8 +44,7 @@ public class WebResponseUtils {
headers.setContentType(mediaType);
headers.setContentLength(bytes.length);
String encodedDocName =
URLEncoder.encode(docName, StandardCharsets.UTF_8.toString())
.replaceAll("\\+", "%20");
URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
headers.setContentDispositionFormData("attachment", encodedDocName);
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}
@@ -61,9 +60,8 @@ public class WebResponseUtils {
// Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
// Close the document
document.close();
return boasToWebResponse(baos, docName);
return baosToWebResponse(baos, docName);
}
}

View File

@@ -25,7 +25,7 @@ public class WebResponseUtilsTest {
String docName = "sample.pdf";
ResponseEntity<byte[]> responseEntity =
WebResponseUtils.boasToWebResponse(baos, docName);
WebResponseUtils.baosToWebResponse(baos, docName);
assertNotNull(responseEntity);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());