mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6a1c2904 | ||
|
|
b4f1c67f65 | ||
|
|
e42175dd9b |
+361
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+193
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+178
-109
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+632
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-26
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user