Compare commits

...
5 changed files with 1380 additions and 154 deletions
@@ -1,26 +1,89 @@
package stirling.software.common.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.util.DateConverter;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.AdobePDFSchema;
import org.apache.xmpbox.schema.DublinCoreSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.schema.XMPMediaManagementSchema;
import org.apache.xmpbox.schema.XMPSchema;
import org.apache.xmpbox.type.AbstractField;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpParsingException;
import org.apache.xmpbox.xml.XmpSerializer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.PdfMetadata;
@Slf4j
@Service
public class PdfMetadataService {
/** ({@code {labels}}). Written by the classify-and-label tool. */
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
public static final String PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/";
private static final Pattern ILLEGAL_XML_NAME_CHARS = Pattern.compile("[^A-Za-z0-9._-]");
private static final List<DateTimeFormatter> DATE_TIME_FORMATTERS =
List.of(
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"),
DateTimeFormatter.ofPattern("d.M.yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("d.M.yyyy HH:mm"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"),
DateTimeFormatter.ofPattern("d/M/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("d/M/yyyy HH:mm"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"),
DateTimeFormatter.ofPattern("M/d/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("M/d/yyyy HH:mm"),
DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"));
private static final List<DateTimeFormatter> DATE_ONLY_FORMATTERS =
List.of(
DateTimeFormatter.ofPattern("yyyy/MM/dd"),
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
DateTimeFormatter.ofPattern("d.M.yyyy"),
DateTimeFormatter.ofPattern("dd.MM.yyyy"),
DateTimeFormatter.ofPattern("d/M/yyyy"),
DateTimeFormatter.ofPattern("dd/MM/yyyy"),
DateTimeFormatter.ofPattern("M/d/yyyy"),
DateTimeFormatter.ofPattern("MM/dd/yyyy"));
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
@@ -38,10 +101,10 @@ public class PdfMetadataService {
}
/**
* Converts ZonedDateTime to Calendar for PDFBox compatibility.
* Converts a {@link ZonedDateTime} to a {@link Calendar} for PDFBox compatibility.
*
* @param zonedDateTime the ZonedDateTime to convert
* @return Calendar instance or null if input is null
* @param zonedDateTime the date-time to convert, or null
* @return Calendar representation, or null if input is null
*/
public static Calendar toCalendar(ZonedDateTime zonedDateTime) {
if (zonedDateTime == null) {
@@ -69,23 +132,66 @@ public class PdfMetadataService {
}
/**
* Parses a date string and converts it to Calendar for PDFBox compatibility.
* Parses a date string into a {@link Calendar} supporting ISO-8601, PDF internal date format
* ("D:YYYYMMDD..."), and common localized date and date-time patterns.
*
* @param dateString the date string in "yyyy/MM/dd HH:mm:ss" format
* @return Calendar instance or null if parsing fails or input is empty
* @param dateString raw date string
* @return parsed Calendar, or null if input is null, blank, or cannot be parsed
*/
public static Calendar parseToCalendar(String dateString) {
if (dateString == null || dateString.trim().isEmpty()) {
if (dateString == null) {
return null;
}
String trimmed = dateString.trim();
if (trimmed.isEmpty()) {
return null;
}
if (trimmed.startsWith("D:")) {
Calendar cal = DateConverter.toCalendar(trimmed);
if (cal != null) {
return cal;
}
}
try {
return toCalendar(ZonedDateTime.parse(trimmed));
} catch (DateTimeParseException ignored) {
}
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
ZonedDateTime zonedDateTime =
LocalDateTime.parse(dateString, formatter).atZone(ZoneId.systemDefault());
return toCalendar(zonedDateTime);
} catch (Exception e) {
return null;
return toCalendar(OffsetDateTime.parse(trimmed).toZonedDateTime());
} catch (DateTimeParseException ignored) {
}
try {
return toCalendar(Instant.parse(trimmed).atZone(ZoneId.systemDefault()));
} catch (DateTimeParseException ignored) {
}
for (DateTimeFormatter dtf : DATE_TIME_FORMATTERS) {
try {
LocalDateTime ldt = LocalDateTime.parse(trimmed, dtf);
return toCalendar(ldt.atZone(ZoneId.systemDefault()));
} catch (DateTimeParseException ignored) {
}
}
for (DateTimeFormatter df : DATE_ONLY_FORMATTERS) {
try {
LocalDate ld = LocalDate.parse(trimmed, df);
return toCalendar(ld.atStartOfDay(ZoneId.systemDefault()));
} catch (DateTimeParseException ignored) {
}
}
if (!trimmed.startsWith("D:")) {
Calendar cal = DateConverter.toCalendar(trimmed);
if (cal != null) {
return cal;
}
}
log.debug("Unparseable date string: '{}'", trimmed);
return null;
}
public PdfMetadata extractMetadataFromPdf(PDDocument pdf) {
@@ -136,7 +242,6 @@ public class PdfMetadataService {
pdf.getDocumentInformation().setCreator(creator);
// Use existing creation date if available, otherwise create new one
Calendar creationCal =
pdfMetadata.getCreationDate() != null
? toCalendar(pdfMetadata.getCreationDate())
@@ -151,7 +256,6 @@ public class PdfMetadataService {
pdf.getDocumentInformation().setSubject(pdfMetadata.getSubject());
pdf.getDocumentInformation().setKeywords(pdfMetadata.getKeywords());
// Convert ZonedDateTime to Calendar for PDFBox compatibility
Calendar modificationCal =
pdfMetadata.getModificationDate() != null
? toCalendar(pdfMetadata.getModificationDate())
@@ -183,12 +287,251 @@ public class PdfMetadataService {
}
/**
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
* Writes document classification JSON into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}.
*
* @param pdf document to update
* @param classificationJson classifier result JSON
*/
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
pdf.setDocumentInformation(info);
}
/**
* Synchronizes standard metadata fields and custom metadata from {@link PDDocumentInformation}
* into the document catalog's XMP metadata stream (/Catalog /Metadata).
*
* <p>Windows Explorer, Adobe Acrobat, and PDF/A validators prioritize the XMP stream over the
* legacy /Info dictionary (ISO 32000-1 §14.3.3). This method synchronizes Dublin Core, XMP
* Basic, Adobe PDF, XMP Media Management (updating InstanceID), and custom metadata (stored in
* the {@value #PDFX_NAMESPACE} schema following Adobe Acrobat convention).
*
* @param document the PDF document to synchronize
* @param customMetadata custom metadata key-value pairs (or null if custom metadata should not
* be modified)
* @throws IOException if XMP serialization or parsing fails
*/
public void synchronizeXmpMetadata(PDDocument document, Map<String, String> customMetadata)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
return;
}
PDDocumentInformation info = document.getDocumentInformation();
if (info == null) {
info = new PDDocumentInformation();
document.setDocumentInformation(info);
}
PDMetadata existingPdMetadata = catalog.getMetadata();
XMPMetadata xmp = null;
if (existingPdMetadata != null) {
try (InputStream is = existingPdMetadata.createInputStream()) {
DomXmpParser parser = new DomXmpParser();
parser.setStrictParsing(false);
xmp = parser.parse(is);
} catch (XmpParsingException e) {
log.debug(
"Failed to parse existing XMP metadata, initializing fresh XMP: {}",
e.getMessage());
}
}
if (xmp == null) {
xmp = XMPMetadata.createXMPMetadata();
}
DublinCoreSchema dc = xmp.getDublinCoreSchema();
if (dc == null) {
dc = xmp.createAndAddDublinCoreSchema();
}
String title = info.getTitle();
AbstractField tp = dc.getProperty("title");
if (tp != null) {
dc.removeProperty(tp);
}
if (title != null && !title.isBlank()) {
dc.setTitle(title);
}
String author = info.getAuthor();
List<String> existingCreators = dc.getCreators();
if (existingCreators != null) {
for (String c : List.copyOf(existingCreators)) {
dc.removeCreator(c);
}
}
if (author != null && !author.isBlank()) {
dc.addCreator(author);
}
String subject = info.getSubject();
AbstractField descProp = dc.getProperty("description");
if (descProp != null) {
dc.removeProperty(descProp);
}
if (subject != null && !subject.isBlank()) {
dc.setDescription(subject);
}
String keywords = info.getKeywords();
List<String> existingSubjects = dc.getSubjects();
if (existingSubjects != null) {
for (String s : List.copyOf(existingSubjects)) {
dc.removeSubject(s);
}
}
if (keywords != null && !keywords.isBlank()) {
for (String kw : keywords.split("[,;]")) {
String trimmed = kw.trim();
if (!trimmed.isEmpty()) {
dc.addSubject(trimmed);
}
}
}
XMPBasicSchema basic = xmp.getXMPBasicSchema();
if (basic == null) {
basic = xmp.createAndAddXMPBasicSchema();
}
Calendar creationDate = info.getCreationDate();
if (creationDate != null) {
basic.setCreateDate(creationDate);
} else {
AbstractField cd = basic.getProperty("CreateDate");
if (cd != null) {
basic.removeProperty(cd);
}
}
Calendar modificationDate = info.getModificationDate();
if (modificationDate != null) {
basic.setModifyDate(modificationDate);
} else {
AbstractField md = basic.getProperty("ModifyDate");
if (md != null) {
basic.removeProperty(md);
}
}
// MetadataDate records when the metadata itself was last modified per ISO 16684-1
basic.setMetadataDate(Calendar.getInstance());
String creator = info.getCreator();
if (creator != null && !creator.isBlank()) {
basic.setCreatorTool(creator);
} else {
AbstractField ct = basic.getProperty("CreatorTool");
if (ct != null) {
basic.removeProperty(ct);
}
}
XMPMediaManagementSchema mm = xmp.getXMPMediaManagementSchema();
if (mm == null) {
mm = xmp.createAndAddXMPMediaManagementSchema();
}
if (mm.getDocumentID() == null) {
mm.setDocumentID("uuid:" + UUID.randomUUID());
}
mm.setInstanceID("uuid:" + UUID.randomUUID());
AdobePDFSchema adobePdf = xmp.getAdobePDFSchema();
if (adobePdf == null) {
adobePdf = xmp.createAndAddAdobePDFSchema();
}
String producer = info.getProducer();
if (producer != null && !producer.isBlank()) {
adobePdf.setProducer(producer);
} else {
AbstractField p = adobePdf.getProperty("Producer");
if (p != null) {
adobePdf.removeProperty(p);
}
}
if (keywords != null && !keywords.isBlank()) {
adobePdf.setKeywords(keywords);
} else {
AbstractField k = adobePdf.getProperty("Keywords");
if (k != null) {
adobePdf.removeProperty(k);
}
}
String trapped = info.getTrapped();
String normalizedTrapped = null;
if ("true".equalsIgnoreCase(trapped)) {
normalizedTrapped = "True";
} else if ("false".equalsIgnoreCase(trapped)) {
normalizedTrapped = "False";
}
if (normalizedTrapped != null) {
adobePdf.setTextPropertyValueAsSimple("Trapped", normalizedTrapped);
} else {
AbstractField t = adobePdf.getProperty("Trapped");
if (t != null) {
adobePdf.removeProperty(t);
}
}
// Adobe Acrobat convention places custom document properties into
// http://ns.adobe.com/pdfx/1.3/
if (customMetadata != null) {
XMPSchema pdfx = xmp.getSchema(PDFX_NAMESPACE);
if (pdfx == null && !customMetadata.isEmpty()) {
pdfx = new XMPSchema(xmp, PDFX_NAMESPACE, "pdfx");
xmp.addSchema(pdfx);
}
if (pdfx != null) {
// Remove deleted custom properties, preserving standard PDF/X properties (e.g.
// GTS_PDFXVersion)
for (AbstractField prop : List.copyOf(pdfx.getAllProperties())) {
String propName = prop.getPropertyName();
if (propName != null && !propName.startsWith("GTS_")) {
boolean retained =
customMetadata.keySet().stream()
.anyMatch(
k ->
sanitizeXmlPropertyName(k.trim())
.equalsIgnoreCase(propName));
if (!retained) {
pdfx.removeProperty(prop);
}
}
}
for (Map.Entry<String, String> entry : customMetadata.entrySet()) {
String rawKey = entry.getKey();
String val = entry.getValue();
if (rawKey != null && !rawKey.trim().isEmpty() && val != null) {
String cleanKey = sanitizeXmlPropertyName(rawKey.trim());
pdfx.setTextPropertyValueAsSimple(cleanKey, val);
}
}
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
// withXpacket = true writes the <?xpacket ... ?> processing instructions required by
// ISO 32000-1 §14.3.2
new XmpSerializer().serialize(xmp, baos, true);
} catch (Exception e) {
throw new IOException("Failed to serialize XMP metadata", e);
}
PDMetadata pdMetadata = new PDMetadata(document);
pdMetadata.importXMPMetadata(baos.toByteArray());
catalog.setMetadata(pdMetadata);
}
private static String sanitizeXmlPropertyName(String key) {
String cleaned = ILLEGAL_XML_NAME_CHARS.matcher(key).replaceAll("_");
if (cleaned.isEmpty()
|| (!Character.isLetter(cleaned.charAt(0)) && cleaned.charAt(0) != '_')) {
cleaned = "_" + cleaned;
}
if (cleaned.toLowerCase(Locale.ROOT).startsWith("xml")) {
cleaned = "_" + cleaned;
}
return cleaned;
}
}
@@ -7,14 +7,27 @@ import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.AdobePDFSchema;
import org.apache.xmpbox.schema.DublinCoreSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.schema.XMPSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -78,7 +91,7 @@ class PdfMetadataServiceTest {
@DisplayName("returns null for unparsable input")
void invalidReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar("not a date"));
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
assertNull(PdfMetadataService.parseToCalendar("abcd-ef-gh"));
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
}
@@ -97,6 +110,54 @@ class PdfMetadataServiceTest {
.toEpochMilli();
assertEquals(expectedMillis, cal.getTimeInMillis());
}
@Test
@DisplayName("parses diverse date formats including 1.1.2025 and ISO")
void parsesDiverseDateFormats() {
Calendar dotCal = PdfMetadataService.parseToCalendar("1.1.2025");
assertNotNull(dotCal);
long expectedDot =
LocalDate.of(2025, 1, 1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedDot, dotCal.getTimeInMillis());
Calendar dashCal = PdfMetadataService.parseToCalendar("2021-06-15");
assertNotNull(dashCal);
long expectedDash =
LocalDate.of(2021, 6, 15)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedDash, dashCal.getTimeInMillis());
Calendar slashCal = PdfMetadataService.parseToCalendar("2025/01/01");
assertNotNull(slashCal);
long expectedSlash =
LocalDate.of(2025, 1, 1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedSlash, slashCal.getTimeInMillis());
Calendar dashTimeCal = PdfMetadataService.parseToCalendar("2025-01-01 14:30:00");
assertNotNull(dashTimeCal);
long expectedDashTime =
LocalDateTime.of(2025, 1, 1, 14, 30, 0)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedDashTime, dashTimeCal.getTimeInMillis());
Calendar isoCal = PdfMetadataService.parseToCalendar("2025-01-01T12:00:00Z");
assertNotNull(isoCal);
assertEquals(
Instant.parse("2025-01-01T12:00:00Z").toEpochMilli(), isoCal.getTimeInMillis());
Calendar pdfCal = PdfMetadataService.parseToCalendar("D:20250101120000");
assertNotNull(pdfCal);
}
}
@Nested
@@ -413,4 +474,135 @@ class PdfMetadataServiceTest {
}
}
}
@Nested
@DisplayName("synchronizeXmpMetadata(PDDocument, Map)")
class SynchronizeXmpMetadataTests {
@Test
@DisplayName("synchronizes all standard and custom fields to XMP stream")
void synchronizesStandardAndCustomFields() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDDocumentInformation info = doc.getDocumentInformation();
info.setTitle("XMP Test Title");
info.setAuthor("XMP Test Author");
info.setSubject("XMP Test Subject");
info.setKeywords("tag1, tag2, tag3");
info.setCreator("XMP Test Creator");
info.setProducer("XMP Test Producer");
info.setTrapped("True");
Calendar creation = Calendar.getInstance();
creation.setTimeInMillis(1_700_000_000_000L);
Calendar modification = Calendar.getInstance();
modification.setTimeInMillis(1_710_000_000_000L);
info.setCreationDate(creation);
info.setModificationDate(modification);
Map<String, String> customMetadata =
Map.of(
"Department", "Engineering",
"Project-Code", "Apollo-11");
service.synchronizeXmpMetadata(doc, customMetadata);
PDMetadata pdMetadata = doc.getDocumentCatalog().getMetadata();
assertNotNull(pdMetadata);
DomXmpParser parser = new DomXmpParser();
parser.setStrictParsing(false);
XMPMetadata xmp = parser.parse(new ByteArrayInputStream(pdMetadata.toByteArray()));
assertNotNull(xmp);
DublinCoreSchema dc = xmp.getDublinCoreSchema();
assertNotNull(dc);
assertEquals("XMP Test Title", dc.getTitle());
assertNotNull(dc.getCreators());
assertEquals("XMP Test Author", dc.getCreators().get(0));
assertEquals("XMP Test Subject", dc.getDescription());
assertNotNull(dc.getSubjects());
assertEquals(3, dc.getSubjects().size());
XMPBasicSchema basic = xmp.getXMPBasicSchema();
assertNotNull(basic);
assertEquals("XMP Test Creator", basic.getCreatorTool());
assertNotNull(basic.getCreateDate());
assertEquals(1_700_000_000_000L, basic.getCreateDate().getTimeInMillis());
assertNotNull(basic.getModifyDate());
assertEquals(1_710_000_000_000L, basic.getModifyDate().getTimeInMillis());
AdobePDFSchema pdfSchema = xmp.getAdobePDFSchema();
assertNotNull(pdfSchema);
assertEquals("XMP Test Producer", pdfSchema.getProducer());
assertEquals("tag1, tag2, tag3", pdfSchema.getKeywords());
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals("Engineering", pdfx.getUnqualifiedTextPropertyValue("Department"));
assertEquals("Apollo-11", pdfx.getUnqualifiedTextPropertyValue("Project-Code"));
}
}
@Test
@DisplayName("removes deleted custom fields on subsequent synchronization")
void removesDeletedCustomFields() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
service.synchronizeXmpMetadata(doc, Map.of("Field1", "Val1", "Field2", "Val2"));
service.synchronizeXmpMetadata(doc, Map.of("Field2", "Val2Updated"));
PDMetadata pdMetadata = doc.getDocumentCatalog().getMetadata();
DomXmpParser parser = new DomXmpParser();
parser.setStrictParsing(false);
XMPMetadata xmp = parser.parse(new ByteArrayInputStream(pdMetadata.toByteArray()));
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertNull(pdfx.getUnqualifiedTextPropertyValue("Field1"));
assertEquals("Val2Updated", pdfx.getUnqualifiedTextPropertyValue("Field2"));
}
}
@Test
@DisplayName(
"preserves standard PDF/X properties like GTS_PDFXVersion during custom metadata synchronization")
void preservesStandardPdfXProperties() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
XMPMetadata initialXmp = XMPMetadata.createXMPMetadata();
XMPSchema pdfxInitial =
new XMPSchema(initialXmp, PdfMetadataService.PDFX_NAMESPACE, "pdfx");
pdfxInitial.setTextPropertyValueAsSimple("GTS_PDFXVersion", "PDF/X-1:2001");
pdfxInitial.setTextPropertyValueAsSimple("OldCustom", "OldValue");
initialXmp.addSchema(pdfxInitial);
ByteArrayOutputStream xmpBaos = new ByteArrayOutputStream();
new XmpSerializer().serialize(initialXmp, xmpBaos, true);
PDMetadata pdMetadata = new PDMetadata(doc);
pdMetadata.importXMPMetadata(xmpBaos.toByteArray());
doc.getDocumentCatalog().setMetadata(pdMetadata);
service.synchronizeXmpMetadata(doc, Map.of("NewCustom", "NewValue"));
PDMetadata updatedMetadata = doc.getDocumentCatalog().getMetadata();
DomXmpParser parser = new DomXmpParser();
parser.setStrictParsing(false);
XMPMetadata xmp =
parser.parse(new ByteArrayInputStream(updatedMetadata.toByteArray()));
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals(
"PDF/X-1:2001", pdfx.getUnqualifiedTextPropertyValue("GTS_PDFXVersion"));
assertEquals("NewValue", pdfx.getUnqualifiedTextPropertyValue("NewCustom"));
assertNull(pdfx.getUnqualifiedTextPropertyValue("OldCustom"));
}
}
}
}
@@ -2,11 +2,17 @@ package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.util.Calendar;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -18,6 +24,8 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,7 +39,6 @@ import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
@@ -41,17 +48,32 @@ import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
@RequiredArgsConstructor
public class MetadataController {
private static final Set<String> STANDARD_KEYS =
Set.of(
"author",
"creationdate",
"creator",
"keywords",
"modificationdate",
"moddate",
"producer",
"subject",
"title",
"trapped",
"deleteall",
"fileinput",
"allrequestparams");
private static final Pattern CUSTOM_KEY_PATTERN = Pattern.compile("^customKey(\\d*)$");
private static final Pattern BRACKET_PARAM_PATTERN =
Pattern.compile("^allRequestParams\\[([^\\]]+)\\]$");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PdfMetadataService pdfMetadataService;
private String checkUndefined(String entry) {
// Check if the string is "undefined"
if ("undefined".equals(entry)) {
// Return null if it is
return null;
}
// Return the original string if it's not "undefined"
return entry;
return "undefined".equals(entry) ? null : entry;
}
@InitBinder
@@ -70,118 +92,161 @@ public class MetadataController {
description =
"This endpoint allows you to update the metadata of a given PDF file. You can"
+ " add, modify, or delete standard and custom metadata fields.")
public ResponseEntity<Resource> metadata(@ModelAttribute MetadataRequest request)
public ResponseEntity<Resource> metadata(
@ModelAttribute MetadataRequest request, HttpServletRequest servletRequest)
throws IOException {
// Extract PDF file from the request object
MultipartFile pdfFile = request.getFileInput();
// Extract metadata information
boolean deleteAll = Boolean.TRUE.equals(request.getDeleteAll());
String author = request.getAuthor();
String creationDate = request.getCreationDate();
String creator = request.getCreator();
String keywords = request.getKeywords();
String modificationDate = request.getModificationDate();
String producer = request.getProducer();
String subject = request.getSubject();
String title = request.getTitle();
String trapped = request.getTrapped();
String author = checkUndefined(request.getAuthor());
String creationDate = checkUndefined(request.getCreationDate());
String creator = checkUndefined(request.getCreator());
String keywords = checkUndefined(request.getKeywords());
String modificationDate = checkUndefined(request.getModificationDate());
String producer = checkUndefined(request.getProducer());
String subject = checkUndefined(request.getSubject());
String title = checkUndefined(request.getTitle());
String trapped = checkUndefined(request.getTrapped());
// Extract additional custom parameters
Map<String, String> allRequestParams = request.getAllRequestParams();
if (allRequestParams == null) {
allRequestParams = new java.util.HashMap<String, String>();
}
// Load the PDF file into a PDDocument with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
// Get the document information from the PDF
PDDocumentInformation info = document.getDocumentInformation();
// Check if each metadata value is "undefined" and set it to null if it is
author = checkUndefined(author);
creationDate = checkUndefined(creationDate);
creator = checkUndefined(creator);
keywords = checkUndefined(keywords);
modificationDate = checkUndefined(modificationDate);
producer = checkUndefined(producer);
subject = checkUndefined(subject);
title = checkUndefined(title);
trapped = checkUndefined(trapped);
// If the "deleteAll" flag is set, remove all metadata from the document
// information
if (deleteAll) {
for (String key : info.getMetadataKeys()) {
info.setCustomMetadataValue(key, null);
Map<String, String> rawParams = new LinkedHashMap<>();
if (request.getAllRequestParams() != null) {
for (Entry<String, String> entry : request.getAllRequestParams().entrySet()) {
String paramName = entry.getKey();
String paramValue = entry.getValue();
Matcher bracketMatcher = BRACKET_PARAM_PATTERN.matcher(paramName);
if (bracketMatcher.matches()) {
rawParams.put(bracketMatcher.group(1), paramValue);
} else if (!STANDARD_KEYS.contains(paramName.toLowerCase(Locale.ROOT))) {
rawParams.put(paramName, paramValue);
}
// Remove metadata from the PDF history
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("Metadata"));
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("PieceInfo"));
author = null;
creationDate = null;
creator = null;
keywords = null;
modificationDate = null;
producer = null;
subject = null;
title = null;
trapped = null;
} else {
// Iterate through the request parameters and set the metadata values
for (Entry<String, String> entry : allRequestParams.entrySet()) {
String key = entry.getKey();
// Check if the key is a standard metadata key
if (!"Author".equalsIgnoreCase(key)
&& !"CreationDate".equalsIgnoreCase(key)
&& !"Creator".equalsIgnoreCase(key)
&& !"Keywords".equalsIgnoreCase(key)
&& !"modificationDate".equalsIgnoreCase(key)
&& !"Producer".equalsIgnoreCase(key)
&& !"Subject".equalsIgnoreCase(key)
&& !"Title".equalsIgnoreCase(key)
&& !"Trapped".equalsIgnoreCase(key)
&& !key.contains("customKey")
&& !key.contains("customValue")) {
info.setCustomMetadataValue(key, entry.getValue());
} else if (key.contains("customKey")) {
try {
int number =
Integer.parseInt(
RegexPatternUtils.getInstance()
.getNumericExtractionPattern()
.matcher(key)
.replaceAll(""));
String customKey = entry.getValue();
String customValue = allRequestParams.get("customValue" + number);
info.setCustomMetadataValue(customKey, customValue);
} catch (NumberFormatException e) {
// Skip invalid custom key entries that don't have valid numeric
// suffixes
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
}
}
}
if (servletRequest != null) {
Map<String, String[]> parameterMap = servletRequest.getParameterMap();
if (parameterMap != null) {
for (Entry<String, String[]> entry : parameterMap.entrySet()) {
String paramName = entry.getKey();
String[] values = entry.getValue();
String paramValue = (values != null && values.length > 0) ? values[0] : "";
Matcher bracketMatcher = BRACKET_PARAM_PATTERN.matcher(paramName);
if (bracketMatcher.matches()) {
rawParams.put(bracketMatcher.group(1), paramValue);
} else if (!STANDARD_KEYS.contains(paramName.toLowerCase(Locale.ROOT))) {
rawParams.put(paramName, paramValue);
}
}
}
// Set creation date using utility method
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
info.setCreationDate(creationDateCal);
}
// Set modification date using utility method
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
info.setModificationDate(modificationDateCal);
info.setCreator(creator);
info.setKeywords(keywords);
info.setAuthor(author);
info.setProducer(producer);
info.setSubject(subject);
info.setTitle(title);
info.setTrapped(trapped);
Map<String, String> customMetadata = new LinkedHashMap<>();
for (Entry<String, String> entry : rawParams.entrySet()) {
String key = entry.getKey();
Matcher matcher = CUSTOM_KEY_PATTERN.matcher(key);
if (matcher.matches()) {
String suffix = matcher.group(1);
String customKey = entry.getValue();
String customValue = rawParams.get("customValue" + suffix);
if (customKey != null && !customKey.trim().isEmpty()) {
customMetadata.put(customKey.trim(), customValue != null ? customValue : "");
}
} else if (!key.startsWith("customValue")
&& !STANDARD_KEYS.contains(key.toLowerCase(Locale.ROOT))) {
if (!key.trim().isEmpty()) {
customMetadata.put(key.trim(), entry.getValue());
}
}
}
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
PDDocumentInformation info = document.getDocumentInformation();
if (info == null) {
info = new PDDocumentInformation();
document.setDocumentInformation(info);
}
if (deleteAll) {
Set<String> existingKeys = info.getMetadataKeys();
if (existingKeys != null) {
for (String key : existingKeys) {
info.setCustomMetadataValue(key, null);
}
}
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null) {
catalog.setMetadata(null);
if (catalog.getCOSObject() != null) {
catalog.getCOSObject().removeItem(COSName.getPDFName("PieceInfo"));
}
}
info.setAuthor(null);
info.setCreationDate(null);
info.setCreator(null);
info.setKeywords(null);
info.setModificationDate(null);
info.setProducer(null);
info.setSubject(null);
info.setTitle(null);
info.setTrapped(null);
} else {
Set<String> existingKeys = info.getMetadataKeys();
if (existingKeys != null) {
for (String existingKey : existingKeys) {
if (!STANDARD_KEYS.contains(existingKey.toLowerCase(Locale.ROOT))
&& !PdfMetadataService.CLASSIFICATION_KEY.equalsIgnoreCase(
existingKey)) {
boolean retained =
customMetadata.keySet().stream()
.anyMatch(k -> k.equalsIgnoreCase(existingKey));
if (!retained) {
info.setCustomMetadataValue(existingKey, null);
}
}
}
}
for (Entry<String, String> entry : customMetadata.entrySet()) {
info.setCustomMetadataValue(entry.getKey(), entry.getValue());
}
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
if (creationDateCal != null) {
info.setCreationDate(creationDateCal);
} else if (creationDate != null
&& (creationDate.isBlank() || "undefined".equalsIgnoreCase(creationDate))) {
info.setCreationDate(null);
}
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
if (modificationDateCal != null) {
info.setModificationDate(modificationDateCal);
} else if (modificationDate != null
&& (modificationDate.isBlank()
|| "undefined".equalsIgnoreCase(modificationDate))) {
info.setModificationDate(null);
}
info.setCreator(creator);
info.setKeywords(keywords);
info.setAuthor(author);
info.setProducer(producer);
info.setSubject(subject);
info.setTitle(title);
String normalizedTrapped = null;
if ("true".equalsIgnoreCase(trapped)) {
normalizedTrapped = "True";
} else if ("false".equalsIgnoreCase(trapped)) {
normalizedTrapped = "False";
} else if ("unknown".equalsIgnoreCase(trapped)) {
normalizedTrapped = "Unknown";
}
info.setTrapped(normalizedTrapped);
pdfMetadataService.synchronizeXmpMetadata(document, customMetadata);
}
document.setDocumentInformation(info);
return WebResponseUtils.pdfDocToWebResponse(
@@ -192,4 +257,8 @@ public class MetadataController {
tempFileManager);
}
}
public ResponseEntity<Resource> metadata(MetadataRequest request) throws IOException {
return metadata(request, null);
}
}
@@ -0,0 +1,632 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Calendar;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.AdobePDFSchema;
import org.apache.xmpbox.schema.DublinCoreSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.schema.XMPMediaManagementSchema;
import org.apache.xmpbox.schema.XMPSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.MetadataRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
@DisplayName("MetadataController & MetadataWriter Full E2E Tests")
class MetadataControllerE2ETest {
private MetadataController metadataController;
private PdfMetadataService pdfMetadataService;
private CustomPDFDocumentFactory pdfDocumentFactory;
private TempFileManager tempFileManager;
@BeforeEach
void setUp() {
ApplicationProperties appProps = new ApplicationProperties();
TempFileRegistry registry = new TempFileRegistry();
tempFileManager = new TempFileManager(registry, appProps);
pdfMetadataService = new PdfMetadataService(appProps, "Stirling-PDF", false, null);
pdfDocumentFactory = new CustomPDFDocumentFactory(pdfMetadataService, tempFileManager);
metadataController =
new MetadataController(pdfDocumentFactory, tempFileManager, pdfMetadataService);
}
private byte[] createBlankPdf() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private byte[] createPdfWithCustomField(String key, String value) throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDDocumentInformation info = doc.getDocumentInformation();
info.setCustomMetadataValue(key, value);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private byte[] createPdfWithExistingXmp(
String oldTitle, String oldAuthor, Calendar oldCreateDate, Calendar oldModifyDate)
throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDDocumentInformation info = doc.getDocumentInformation();
info.setTitle(oldTitle);
info.setAuthor(oldAuthor);
info.setCreationDate(oldCreateDate);
info.setModificationDate(oldModifyDate);
XMPMetadata xmp = XMPMetadata.createXMPMetadata();
DublinCoreSchema dc = xmp.createAndAddDublinCoreSchema();
dc.setTitle(oldTitle);
dc.addCreator(oldAuthor);
XMPBasicSchema basic = xmp.createAndAddXMPBasicSchema();
basic.setCreateDate(oldCreateDate);
basic.setModifyDate(oldModifyDate);
ByteArrayOutputStream xmpOut = new ByteArrayOutputStream();
new XmpSerializer().serialize(xmp, xmpOut, true);
PDMetadata pdMetadata = new PDMetadata(doc);
pdMetadata.importXMPMetadata(xmpOut.toByteArray());
doc.getDocumentCatalog().setMetadata(pdMetadata);
ByteArrayOutputStream docOut = new ByteArrayOutputStream();
doc.save(docOut);
return docOut.toByteArray();
}
}
private PDDocument loadResponsePdf(ResponseEntity<Resource> response) throws IOException {
assertNotNull(response);
assertNotNull(response.getBody());
byte[] bytes = response.getBody().getInputStream().readAllBytes();
assertTrue(bytes.length > 0, "Response PDF should not be empty");
return Loader.loadPDF(bytes);
}
private XMPMetadata loadXmp(PDDocument doc) throws Exception {
PDMetadata pdMetadata = doc.getDocumentCatalog().getMetadata();
assertNotNull(pdMetadata, "XMP Metadata stream should not be null in Catalog");
DomXmpParser parser = new DomXmpParser();
parser.setStrictParsing(false);
return parser.parse(new ByteArrayInputStream(pdMetadata.toByteArray()));
}
@Test
@DisplayName(
"Full standard metadata round-trip with '1.1.2025' date synchronized in Info & XMP")
void testStandardMetadataWithDotDate_RoundTrip() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", inputBytes);
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setTitle("Quarterly Report 2025");
request.setAuthor("Balazs Szucs");
request.setSubject("Financial Analysis");
request.setKeywords("finance, report, 2025, q1");
request.setCreator("Stirling PDF Automation");
request.setProducer("Stirling-PDF Producer");
request.setTrapped("True");
request.setCreationDate("1.1.2025");
request.setModificationDate("1.1.2025");
request.setAllRequestParams(new HashMap<>());
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertNotNull(info);
assertEquals("Quarterly Report 2025", info.getTitle());
assertEquals("Balazs Szucs", info.getAuthor());
assertEquals("Financial Analysis", info.getSubject());
assertEquals("finance, report, 2025, q1", info.getKeywords());
assertEquals("Stirling PDF Automation", info.getCreator());
assertEquals("Stirling-PDF Producer", info.getProducer());
assertEquals("True", info.getTrapped());
Calendar creationDate = info.getCreationDate();
assertNotNull(creationDate, "Creation date in Info dictionary must not be null");
assertEquals(2025, creationDate.get(Calendar.YEAR));
assertEquals(Calendar.JANUARY, creationDate.get(Calendar.MONTH));
assertEquals(1, creationDate.get(Calendar.DAY_OF_MONTH));
Calendar modDate = info.getModificationDate();
assertNotNull(modDate, "Modification date in Info dictionary must not be null");
assertEquals(2025, modDate.get(Calendar.YEAR));
assertEquals(Calendar.JANUARY, modDate.get(Calendar.MONTH));
assertEquals(1, modDate.get(Calendar.DAY_OF_MONTH));
XMPMetadata xmp = loadXmp(resultDoc);
assertNotNull(xmp);
DublinCoreSchema dc = xmp.getDublinCoreSchema();
assertNotNull(dc);
assertEquals("Quarterly Report 2025", dc.getTitle());
assertNotNull(dc.getCreators());
assertTrue(dc.getCreators().contains("Balazs Szucs"));
assertEquals("Financial Analysis", dc.getDescription());
assertNotNull(dc.getSubjects());
assertTrue(dc.getSubjects().contains("finance"));
assertTrue(dc.getSubjects().contains("report"));
assertTrue(dc.getSubjects().contains("2025"));
assertTrue(dc.getSubjects().contains("q1"));
XMPBasicSchema basic = xmp.getXMPBasicSchema();
assertNotNull(basic);
assertEquals("Stirling PDF Automation", basic.getCreatorTool());
assertNotNull(basic.getCreateDate());
assertEquals(2025, basic.getCreateDate().get(Calendar.YEAR));
assertEquals(Calendar.JANUARY, basic.getCreateDate().get(Calendar.MONTH));
assertEquals(1, basic.getCreateDate().get(Calendar.DAY_OF_MONTH));
assertNotNull(basic.getModifyDate());
assertEquals(2025, basic.getModifyDate().get(Calendar.YEAR));
assertEquals(Calendar.JANUARY, basic.getModifyDate().get(Calendar.MONTH));
assertEquals(1, basic.getModifyDate().get(Calendar.DAY_OF_MONTH));
assertNotNull(basic.getMetadataDate());
XMPMediaManagementSchema mm = xmp.getXMPMediaManagementSchema();
assertNotNull(mm);
assertNotNull(mm.getInstanceID());
assertTrue(mm.getInstanceID().startsWith("uuid:"));
AdobePDFSchema pdfSchema = xmp.getAdobePDFSchema();
assertNotNull(pdfSchema);
assertEquals("Stirling-PDF Producer", pdfSchema.getProducer());
assertEquals("finance, report, 2025, q1", pdfSchema.getKeywords());
assertEquals("True", pdfSchema.getUnqualifiedTextPropertyValue("Trapped"));
}
}
@Test
@DisplayName(
"Overwrites pre-existing XMP metadata (Windows Explorer / PDF Property Handler bug fix)")
void testPreExistingXmpOverwritten_WindowsExplorerCompatibility() throws Exception {
Calendar oldDate = Calendar.getInstance();
oldDate.set(2018, Calendar.MAY, 10, 8, 30, 0);
byte[] inputBytes =
createPdfWithExistingXmp(
"Ancient Old Title", "Ancient Old Author", oldDate, oldDate);
MockMultipartFile file =
new MockMultipartFile("fileInput", "doc_old.pdf", "application/pdf", inputBytes);
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setTitle("Brand New Title 2025");
request.setAuthor("Brand New Author");
request.setCreationDate("1.1.2025");
request.setModificationDate("1.1.2025");
request.setAllRequestParams(new HashMap<>());
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertEquals("Brand New Title 2025", info.getTitle());
assertEquals("Brand New Author", info.getAuthor());
assertEquals(2025, info.getCreationDate().get(Calendar.YEAR));
assertEquals(2025, info.getModificationDate().get(Calendar.YEAR));
XMPMetadata xmp = loadXmp(resultDoc);
DublinCoreSchema dc = xmp.getDublinCoreSchema();
assertEquals("Brand New Title 2025", dc.getTitle());
assertFalse(
dc.getCreators().contains("Ancient Old Author"),
"Old author must not remain in XMP");
assertTrue(
dc.getCreators().contains("Brand New Author"),
"New author must be present in XMP");
XMPBasicSchema basic = xmp.getXMPBasicSchema();
assertEquals(
2025,
basic.getCreateDate().get(Calendar.YEAR),
"Creation date in XMP must be 2025, not 2018");
assertEquals(
Calendar.JANUARY,
basic.getCreateDate().get(Calendar.MONTH),
"Creation date month must be January");
assertEquals(
1,
basic.getCreateDate().get(Calendar.DAY_OF_MONTH),
"Creation date day must be 1st");
assertEquals(
2025,
basic.getModifyDate().get(Calendar.YEAR),
"Modification date in XMP must be 2025, not 2018");
}
}
@Test
@DisplayName("Randomized custom metadata in paired form format (customKeyN / customValueN)")
void testRandomizedCustomMetadata_PairedFormat() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "random_custom.pdf", "application/pdf", inputBytes);
Map<String, String> expectedCustom = new LinkedHashMap<>();
Map<String, String> requestParams = new HashMap<>();
Random random = new Random(42);
for (int i = 1; i <= 15; i++) {
String key = "CustomField_" + i + "_" + UUID.randomUUID().toString().substring(0, 8);
String value = "Val_" + random.nextInt(1000000) + "_" + UUID.randomUUID().toString();
expectedCustom.put(key, value);
requestParams.put("customKey" + i, key);
requestParams.put("customValue" + i, value);
}
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setAllRequestParams(requestParams);
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx, "XMP pdfx schema must be present for custom metadata");
for (Map.Entry<String, String> entry : expectedCustom.entrySet()) {
String expectedKey = entry.getKey();
String expectedVal = entry.getValue();
assertEquals(
expectedVal,
info.getCustomMetadataValue(expectedKey),
"Custom key " + expectedKey + " must match in /Info dictionary");
assertEquals(
expectedVal,
pdfx.getUnqualifiedTextPropertyValue(expectedKey),
"Custom key " + expectedKey + " must match in XMP pdfx schema");
}
}
}
@Test
@DisplayName("Single unindexed custom metadata pair (customKey / customValue)")
void testUnindexedCustomMetadataPair() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "single_pair.pdf", "application/pdf", inputBytes);
Map<String, String> requestParams = new HashMap<>();
requestParams.put("customKey", "Department");
requestParams.put("customValue", "Engineering");
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setAllRequestParams(requestParams);
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertEquals("Engineering", info.getCustomMetadataValue("Department"));
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals("Engineering", pdfx.getUnqualifiedTextPropertyValue("Department"));
}
}
@Test
@DisplayName("Direct key-value custom metadata map in allRequestParams")
void testDirectCustomMetadataMap() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile("fileInput", "direct_map.pdf", "application/pdf", inputBytes);
Map<String, String> requestParams = new LinkedHashMap<>();
requestParams.put("ProjectName", "Gemini-Apollo");
requestParams.put("SecurityClearance", "Level-5");
requestParams.put("CostCenter", "CC-4002");
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setAllRequestParams(requestParams);
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertEquals("Gemini-Apollo", info.getCustomMetadataValue("ProjectName"));
assertEquals("Level-5", info.getCustomMetadataValue("SecurityClearance"));
assertEquals("CC-4002", info.getCustomMetadataValue("CostCenter"));
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals("Gemini-Apollo", pdfx.getUnqualifiedTextPropertyValue("ProjectName"));
assertEquals("Level-5", pdfx.getUnqualifiedTextPropertyValue("SecurityClearance"));
assertEquals("CC-4002", pdfx.getUnqualifiedTextPropertyValue("CostCenter"));
}
}
@Test
@DisplayName(
"Multipart HttpServletRequest parameters with bracket notation and top-level fields")
void testMultipartServletRequest_BracketAndTopLevelParams() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "servlet_request.pdf", "application/pdf", inputBytes);
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
servletRequest.setParameter("allRequestParams[ClientName]", "Acme Corporation");
servletRequest.setParameter("allRequestParams[ContractId]", "CTR-2025-001");
servletRequest.setParameter("customKey1", "LeadArchitect");
servletRequest.setParameter("customValue1", "Jane Doe");
servletRequest.setParameter("DocumentVersion", "3.2.1");
ResponseEntity<Resource> response = metadataController.metadata(request, servletRequest);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertEquals("Acme Corporation", info.getCustomMetadataValue("ClientName"));
assertEquals("CTR-2025-001", info.getCustomMetadataValue("ContractId"));
assertEquals("Jane Doe", info.getCustomMetadataValue("LeadArchitect"));
assertEquals("3.2.1", info.getCustomMetadataValue("DocumentVersion"));
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals("Acme Corporation", pdfx.getUnqualifiedTextPropertyValue("ClientName"));
assertEquals("CTR-2025-001", pdfx.getUnqualifiedTextPropertyValue("ContractId"));
assertEquals("Jane Doe", pdfx.getUnqualifiedTextPropertyValue("LeadArchitect"));
assertEquals("3.2.1", pdfx.getUnqualifiedTextPropertyValue("DocumentVersion"));
}
}
@Test
@DisplayName("Custom metadata deletion: removed keys are purged from both /Info and XMP")
void testCustomMetadataDeletion() throws Exception {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file1 =
new MockMultipartFile("fileInput", "initial.pdf", "application/pdf", inputBytes);
MetadataRequest req1 = new MetadataRequest();
req1.setFileInput(file1);
req1.setDeleteAll(false);
req1.setAllRequestParams(
Map.of(
"customKey1",
"FieldA",
"customValue1",
"ValueA",
"customKey2",
"FieldB",
"customValue2",
"ValueB",
"customKey3",
"FieldC",
"customValue3",
"ValueC"));
ResponseEntity<Resource> res1 = metadataController.metadata(req1);
byte[] step1Bytes = res1.getBody().getInputStream().readAllBytes();
MockMultipartFile file2 =
new MockMultipartFile("fileInput", "step2.pdf", "application/pdf", step1Bytes);
MetadataRequest req2 = new MetadataRequest();
req2.setFileInput(file2);
req2.setDeleteAll(false);
req2.setAllRequestParams(
Map.of(
"customKey1",
"FieldB",
"customValue1",
"ValueB_Updated",
"customKey2",
"FieldD",
"customValue2",
"ValueD_New"));
ResponseEntity<Resource> res2 = metadataController.metadata(req2);
try (PDDocument resultDoc = loadResponsePdf(res2)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertNull(
info.getCustomMetadataValue("FieldA"),
"FieldA must be removed from /Info dictionary");
assertNull(
info.getCustomMetadataValue("FieldC"),
"FieldC must be removed from /Info dictionary");
assertEquals("ValueB_Updated", info.getCustomMetadataValue("FieldB"));
assertEquals("ValueD_New", info.getCustomMetadataValue("FieldD"));
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertNull(
pdfx.getUnqualifiedTextPropertyValue("FieldA"),
"FieldA must be removed from XMP pdfx schema");
assertNull(
pdfx.getUnqualifiedTextPropertyValue("FieldC"),
"FieldC must be removed from XMP pdfx schema");
assertEquals("ValueB_Updated", pdfx.getUnqualifiedTextPropertyValue("FieldB"));
assertEquals("ValueD_New", pdfx.getUnqualifiedTextPropertyValue("FieldD"));
}
}
@Test
@DisplayName("deleteAll = true completely purges /Info, XMP stream, and catalog PieceInfo")
void testDeleteAllPurgesInfoAndXmp() throws Exception {
Calendar oldDate = Calendar.getInstance();
oldDate.set(2022, Calendar.AUGUST, 15, 10, 0, 0);
byte[] inputBytes =
createPdfWithExistingXmp("To Delete", "Delete Author", oldDate, oldDate);
MockMultipartFile file =
new MockMultipartFile("fileInput", "delete_all.pdf", "application/pdf", inputBytes);
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(true);
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertNull(info.getTitle());
assertNull(info.getAuthor());
assertNull(info.getCreationDate());
assertNull(info.getModificationDate());
PDDocumentCatalog catalog = resultDoc.getDocumentCatalog();
assertNull(catalog.getMetadata(), "Catalog XMP metadata must be null after deleteAll");
}
}
@Test
@DisplayName("Various date formats are accurately parsed and saved in Info and XMP")
void testVariousDateFormatsSupported() throws Exception {
String[] dateInputs =
new String[] {
"2025/01/15 10:20:30",
"2025-01-15 10:20:30",
"2025-01-15",
"2025/01/15",
"15.1.2025",
"15.01.2025 10:20:30",
"2025-01-15T10:20:30Z",
"D:20250115102030"
};
for (String dateStr : dateInputs) {
byte[] inputBytes = createBlankPdf();
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test_date.pdf", "application/pdf", inputBytes);
MetadataRequest request = new MetadataRequest();
request.setFileInput(file);
request.setDeleteAll(false);
request.setCreationDate(dateStr);
request.setModificationDate(dateStr);
ResponseEntity<Resource> response = metadataController.metadata(request);
try (PDDocument resultDoc = loadResponsePdf(response)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertNotNull(
info.getCreationDate(),
"Creation date must be parsed for format: " + dateStr);
assertEquals(
2025,
info.getCreationDate().get(Calendar.YEAR),
"Year must be 2025 for: " + dateStr);
assertEquals(
Calendar.JANUARY,
info.getCreationDate().get(Calendar.MONTH),
"Month must be January for: " + dateStr);
assertEquals(
15,
info.getCreationDate().get(Calendar.DAY_OF_MONTH),
"Day must be 15 for: " + dateStr);
XMPMetadata xmp = loadXmp(resultDoc);
XMPBasicSchema basic = xmp.getXMPBasicSchema();
assertNotNull(
basic.getCreateDate(),
"XMP CreateDate must be present for format: " + dateStr);
assertEquals(
2025,
basic.getCreateDate().get(Calendar.YEAR),
"XMP Year must be 2025 for: " + dateStr);
assertEquals(
Calendar.JANUARY,
basic.getCreateDate().get(Calendar.MONTH),
"XMP Month must be January for: " + dateStr);
assertEquals(
15,
basic.getCreateDate().get(Calendar.DAY_OF_MONTH),
"XMP Day must be 15 for: " + dateStr);
}
}
}
@Test
@DisplayName("Updates custom metadata case-insensitively without losing existing keys")
void testCaseInsensitiveCustomKeyUpdate() throws Exception {
byte[] pdfWithCustom = createPdfWithCustomField("ProjectCode", "Apollo-11");
MockMultipartFile inputFile =
new MockMultipartFile("fileInput", "test.pdf", "application/pdf", pdfWithCustom);
MetadataRequest request = new MetadataRequest();
request.setFileInput(inputFile);
request.setAllRequestParams(Map.of("projectcode", "Apollo-12"));
ResponseEntity<Resource> response = metadataController.metadata(request);
byte[] resultBytes = response.getBody().getContentAsByteArray();
try (PDDocument resultDoc = Loader.loadPDF(resultBytes)) {
PDDocumentInformation info = resultDoc.getDocumentInformation();
assertEquals("Apollo-12", info.getCustomMetadataValue("projectcode"));
XMPMetadata xmp = loadXmp(resultDoc);
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
assertNotNull(pdfx);
assertEquals("Apollo-12", pdfx.getUnqualifiedTextPropertyValue("projectcode"));
}
}
}
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -25,12 +26,14 @@ import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.misc.MetadataRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class MetadataControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfMetadataService pdfMetadataService;
@InjectMocks private MetadataController metadataController;
private PDDocument mockDocument;
@@ -73,7 +76,7 @@ class MetadataControllerTest {
void testMetadata_deleteAllClearsAllMetadata() throws Exception {
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(mockDocument);
when(mockDocument.getDocumentInformation()).thenReturn(mockInfo);
when(mockInfo.getMetadataKeys()).thenReturn(java.util.Collections.emptySet());
when(mockInfo.getMetadataKeys()).thenReturn(Set.of());
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
COSDictionary cosDict = mock(COSDictionary.class);
when(mockCatalog.getCOSObject()).thenReturn(cosDict);
@@ -84,13 +87,11 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// WebResponseUtils.pdfDocToWebResponse may fail in test context
// but we verify the delete-all logic executed
} catch (Exception _) {
}
verify(mockInfo).getMetadataKeys();
verify(cosDict, times(2)).removeItem(any());
verify(cosDict, times(1)).removeItem(any());
}
@Test
@@ -113,8 +114,7 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected - pdfDocToWebResponse may fail
} catch (Exception _) {
}
verify(mockInfo).setAuthor("TestAuthor");
@@ -141,8 +141,7 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
verify(mockInfo).setAuthor(null);
@@ -166,8 +165,7 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
verify(mockInfo).setCustomMetadataValue("myKey", "myValue");
@@ -186,11 +184,9 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
// Should not throw NPE - null params handled gracefully
verify(mockDocument).setDocumentInformation(mockInfo);
}
@@ -210,8 +206,7 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
verify(mockInfo).setCustomMetadataValue("MyCustomField", "MyCustomValue");
@@ -248,11 +243,9 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
// Standard keys in allRequestParams should not be set via setCustomMetadataValue
verify(mockInfo, never()).setCustomMetadataValue(eq("Author"), any());
verify(mockInfo, never()).setCustomMetadataValue(eq("Title"), any());
verify(mockInfo, never()).setCustomMetadataValue(eq("Subject"), any());
@@ -266,17 +259,15 @@ class MetadataControllerTest {
MetadataRequest request = new MetadataRequest();
request.setFileInput(mockFile);
request.setDeleteAll(null); // null should be treated as false
request.setDeleteAll(null);
request.setAllRequestParams(new HashMap<>());
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
// Should not call getMetadataKeys (that's only done when deleteAll=true)
verify(mockInfo, never()).getMetadataKeys();
verify(mockCatalog, never()).getCOSObject();
}
@Test
@@ -293,8 +284,7 @@ class MetadataControllerTest {
try {
metadataController.metadata(request);
} catch (Exception e) {
// Expected
} catch (Exception _) {
}
verify(mockInfo).setCreationDate(any());