diff --git a/CHANGES.txt b/CHANGES.txt
index 92606e27fdd..e38825e22fe 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -28,6 +28,9 @@ Release 4.0.0-beta-1 - 6/29/2026
false, so an out-of-the-box tika-grpc server no longer accepts per-request
configuration or runtime store mutations (TIKA-4764).
+ * Unified XMP extraction across containers; adds HEIF/HEIC and WebP XMP,
+ including Samsung/Google Motion Photo (TIKA-4775).
+
OTHER CHANGES
* Release artifacts are now channel-specific. Maven Central gets slim
diff --git a/tika-bundles/tika-bundle-standard/pom.xml b/tika-bundles/tika-bundle-standard/pom.xml
index 5991c75d87e..168e127f356 100644
--- a/tika-bundles/tika-bundle-standard/pom.xml
+++ b/tika-bundles/tika-bundle-standard/pom.xml
@@ -113,8 +113,6 @@
pdfbox|
pdfbox-tools|
fontbox|
- jempbox|
- xmpbox|
bcjmail-jdk18on|
bcprov-jdk18on|
bcpkix-jdk18on|
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Google.java b/tika-core/src/main/java/org/apache/tika/metadata/Google.java
new file mode 100644
index 00000000000..61c20938677
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Google.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.metadata;
+
+/**
+ * Properties from the Google Photos XMP namespaces used by Motion Photos and the legacy
+ * MicroVideo format. See
+ * the Motion Photo
+ * format.
+ *
+ * Files use the {@code Camera} (current) or {@code GCamera} (legacy) prefix, but the namespace
+ * URI is the same; the image XMP handler maps by URI, so these keys are stable regardless of
+ * prefix.
+ */
+public interface Google {
+
+ String CAMERA_NS = "http://ns.google.com/photos/1.0/camera/";
+
+ String CONTAINER_NS = "http://ns.google.com/photos/1.0/container/";
+
+ String ITEM_NS = "http://ns.google.com/photos/1.0/container/item/";
+
+ Property MOTION_PHOTO = Property.externalText("Camera:MotionPhoto");
+
+ Property MOTION_PHOTO_VERSION = Property.externalText("Camera:MotionPhotoVersion");
+
+ Property MOTION_PHOTO_PRESENTATION_TIMESTAMP_US =
+ Property.externalText("Camera:MotionPhotoPresentationTimestampUs");
+
+ Property MICRO_VIDEO = Property.externalText("Camera:MicroVideo");
+
+ Property MICRO_VIDEO_VERSION = Property.externalText("Camera:MicroVideoVersion");
+
+ Property MICRO_VIDEO_OFFSET = Property.externalText("Camera:MicroVideoOffset");
+
+ Property MICRO_VIDEO_PRESENTATION_TIMESTAMP_US =
+ Property.externalText("Camera:MicroVideoPresentationTimestampUs");
+}
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/PDF.java b/tika-core/src/main/java/org/apache/tika/metadata/PDF.java
index 51451e71dfa..1f6d9c3284c 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/PDF.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/PDF.java
@@ -38,6 +38,9 @@ public interface PDF {
*/
Property EOF_OFFSETS = Property.externalRealSeq(PDF_PREFIX + "eofOffsets");
+ /** Trapped flag from the XMP {@code pdf:} schema; docinfo counterpart {@link #DOC_INFO_TRAPPED}. */
+ Property TRAPPED = Property.internalText(PDF_PREFIX + "Trapped");
+
/**
* Prefix to be used for properties that record what was stored
* in the docinfo section (as opposed to XMP)
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java b/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java
index af4ababb08a..be142f81dd4 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java
@@ -47,6 +47,9 @@ public interface Photoshop {
PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ColorMode",
_COLOR_MODE_CHOICES_INDEXED);
+ Property ICC_PROFILE = Property.internalText(
+ PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ICCProfile");
+
Property CAPTION_WRITER = Property.internalText(
PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "CaptionWriter");
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java b/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java
index fe5fd0ec39c..f3de5ec8b64 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java
@@ -126,4 +126,16 @@ public interface TIFF {
Property ORIGINAL_DATE = Property.internalDate("exif:DateTimeOriginal");
Property EXIF_PAGE_COUNT = Property.externalInteger("exif:PageCount");
+
+ /** Camera-body serial number. */
+ Property SERIAL_NUMBER = Property.internalText("aux:SerialNumber");
+
+ /** Human-readable lens description, e.g. "70-200mm f/2.8". */
+ Property LENS = Property.internalText("aux:Lens");
+
+ /** Lens spec: min/max focal length and aperture. */
+ Property LENS_INFO = Property.internalText("aux:LensInfo");
+
+ /** Numeric lens identifier. */
+ Property LENS_ID = Property.internalText("aux:LensID");
}
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java b/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java
index 2a81fa254fd..66bad542385 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java
@@ -92,6 +92,12 @@ public interface XMPMM {
*/
Property HISTORY_SOFTWARE_AGENT = Property.externalTextBag(PREFIX_ + "History:SoftwareAgent");
+ /** Part of the resource changed by the action (XMPMM history section). */
+ Property HISTORY_CHANGED = Property.externalTextBag(PREFIX_ + "History:Changed");
+
+ /** Additional parameters describing the action (XMPMM history section). */
+ Property HISTORY_PARAMETERS = Property.externalTextBag(PREFIX_ + "History:Parameters");
+
/**
* Document id for the document that this document
* was derived from
@@ -104,4 +110,12 @@ public interface XMPMM {
*/
Property DERIVED_FROM_INSTANCEID = Property.externalText(PREFIX_ + "DerivedFrom:InstanceID");
+ /** Original document id of the source this document was derived from. */
+ Property DERIVED_FROM_ORIGINAL_DOCUMENTID =
+ Property.externalText(PREFIX_ + "DerivedFrom:OriginalDocumentID");
+
+ /** Rendition class of the source this document was derived from. */
+ Property DERIVED_FROM_RENDITION_CLASS =
+ Property.externalText(PREFIX_ + "DerivedFrom:RenditionClass");
+
}
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java b/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java
new file mode 100644
index 00000000000..a90b2054a17
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.metadata;
+
+/**
+ * Metadata keys for values that derive strictly from the XMP {@code tiff:} / {@code exif:}
+ * schemas, mirroring {@link TIFF}. Tika folds these into the canonical {@link TIFF} keys, where
+ * the same value can also arrive from binary EXIF; these parallel keys preserve provenance so a
+ * consumer can distinguish an XMP-sourced value from a binary one.
+ *
+ * Same pattern as {@link XMPDC}. Deliberately not implemented by {@link Metadata}; reference it
+ * directly.
+ */
+public interface XMPTIFF {
+
+ String PREFIX_TIFF = "xmp" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "tiff";
+ String PREFIX_EXIF = "xmp" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "exif";
+
+ Property EQUIPMENT_MAKE = Property.internalText(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Make");
+
+ Property EQUIPMENT_MODEL = Property.internalText(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Model");
+
+ Property SOFTWARE = Property.internalText(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Software");
+
+ Property IMAGE_WIDTH = Property.internalInteger(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ImageWidth");
+
+ Property IMAGE_LENGTH = Property.internalInteger(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ImageLength");
+
+ Property BITS_PER_SAMPLE = Property.internalIntegerSequence(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "BitsPerSample");
+
+ Property SAMPLES_PER_PIXEL = Property.internalInteger(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "SamplesPerPixel");
+
+ Property ORIENTATION = Property.internalText(
+ PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Orientation");
+
+ Property ORIGINAL_DATE = Property.internalDate(
+ PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "DateTimeOriginal");
+
+ Property ISO_SPEED_RATINGS = Property.internalIntegerSequence(
+ PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "IsoSpeedRatings");
+
+ /** Valid image width; folded into canonical {@link TIFF#IMAGE_WIDTH}. */
+ Property PIXEL_X_DIMENSION = Property.internalInteger(
+ PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "PixelXDimension");
+
+ /** Valid image height; folded into canonical {@link TIFF#IMAGE_LENGTH}. */
+ Property PIXEL_Y_DIMENSION = Property.internalInteger(
+ PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "PixelYDimension");
+}
diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml
index eb541e18752..8625495c0da 100644
--- a/tika-parent/pom.xml
+++ b/tika-parent/pom.xml
@@ -381,7 +381,6 @@
3.0.5
1.82
2.0.6.1
- 1.8.17
12.1.11
12.1.11
2.0.0
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java
index 33ee3a49800..a7439f42566 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java
@@ -165,7 +165,7 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata
metadataExtractor.parseRawExif(stream, extensionLength, true);
break;
case EXTENSION_TAG_XMP:
- handleXMP(stream, extensionLength, metadataExtractor);
+ handleXMP(stream, extensionLength, metadata, parseContext);
break;
default:
IOUtils.skipFully(stream, extensionLength);
@@ -186,7 +186,8 @@ public int getMaxRecordLength() {
return this.maxRecordLength;
}
- protected void handleXMP(InputStream stream, int xmpLength, ImageMetadataExtractor extractor)
+ protected void handleXMP(InputStream stream, int xmpLength, Metadata metadata,
+ ParseContext context)
throws IOException, TikaException, SAXException {
if (xmpLength < 0) {
throw new TikaException("xmp length must be >= 0");
@@ -199,6 +200,6 @@ protected void handleXMP(InputStream stream, int xmpLength, ImageMetadataExtract
}
byte[] xmp = new byte[xmpLength];
IOUtils.readFully(stream, xmp);
- extractor.parseRawXMP(xmp);
+ ImageXmp.extractRaw(xmp, metadata, context);
}
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java
new file mode 100644
index 00000000000..faedf073395
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.drew.lang.SequentialByteArrayReader;
+import com.drew.lang.SequentialReader;
+
+/**
+ * Reassembles a large XMP packet split across JPEG APP1 segments ("Extended XMP", XMP Part 3).
+ * Chunk stitching is borrowed almost verbatim from metadata-extractor's {@code XmpReader}
+ * (Apache 2.0, Drew Noakes); we find the GUID by scanning the standard packet since Tika parses
+ * the XMP itself.
+ */
+final class ExtendedXmp {
+
+ private static final String XMP_JPEG_PREAMBLE = "http://ns.adobe.com/xap/1.0/\u0000";
+ private static final String XMP_EXTENSION_JPEG_PREAMBLE =
+ "http://ns.adobe.com/xmp/extension/\u0000";
+ private static final int EXTENDED_XMP_GUID_LENGTH = 32;
+ private static final int EXTENDED_XMP_INT_LENGTH = 4;
+ // Tika: cap the declared length so a hostile chunk can't drive a huge allocation (not upstream).
+ private static final int MAX_EXTENDED = 64 * 1024 * 1024;
+ // allow any run of attribute/element syntax (=, quotes, '>', whitespace, newlines) before the GUID
+ private static final Pattern HAS_EXTENDED =
+ Pattern.compile("HasExtendedXMP[\"'>=\\s]*([A-Fa-f0-9]{32})");
+
+ private ExtendedXmp() {
+ }
+
+ /** XMP packets (standard + reassembled extended, if any) from a JPEG's APP1 segment payloads. */
+ static List assemble(Iterable segments) {
+ // adapted from metadata-extractor XmpReader.readJpegSegments
+ final int preambleLength = XMP_JPEG_PREAMBLE.length();
+ final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length();
+ String guid = null;
+ byte[] standard = null;
+ byte[] extendedBuffer = null;
+
+ for (byte[] segmentBytes : segments) {
+ if (segmentBytes.length >= preambleLength && XMP_JPEG_PREAMBLE
+ .equalsIgnoreCase(new String(segmentBytes, 0, preambleLength, US_ASCII))) {
+ standard = new byte[segmentBytes.length - preambleLength];
+ System.arraycopy(segmentBytes, preambleLength, standard, 0, standard.length);
+ guid = findGuid(standard);
+ extendedBuffer = null; // a new standard packet starts a fresh extended assembly
+ } else if (guid != null && segmentBytes.length >= extensionPreambleLength
+ && XMP_EXTENSION_JPEG_PREAMBLE.equalsIgnoreCase(
+ new String(segmentBytes, 0, extensionPreambleLength, US_ASCII))) {
+ extendedBuffer = processExtendedXMPChunk(segmentBytes, guid, extendedBuffer);
+ }
+ }
+
+ List packets = new ArrayList<>();
+ if (standard != null) {
+ packets.add(standard);
+ }
+ if (extendedBuffer != null) {
+ packets.add(extendedBuffer);
+ }
+ return packets;
+ }
+
+ private static String findGuid(byte[] standard) {
+ Matcher m = HAS_EXTENDED.matcher(new String(standard, US_ASCII));
+ return m.find() ? m.group(1) : null;
+ }
+
+ // Borrowed almost verbatim from metadata-extractor XmpReader.processExtendedXMPChunk (Apache 2.0).
+ private static byte[] processExtendedXMPChunk(byte[] segmentBytes, String extendedXMPGUID,
+ byte[] extendedXMPBuffer) {
+ final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length();
+ final int segmentLength = segmentBytes.length;
+ final int totalOffset = extensionPreambleLength + EXTENDED_XMP_GUID_LENGTH
+ + EXTENDED_XMP_INT_LENGTH + EXTENDED_XMP_INT_LENGTH;
+ if (segmentLength >= totalOffset) {
+ try {
+ final SequentialReader reader = new SequentialByteArrayReader(segmentBytes);
+ reader.skip(extensionPreambleLength);
+ final String segmentGUID = reader.getString(EXTENDED_XMP_GUID_LENGTH);
+ if (extendedXMPGUID.equals(segmentGUID)) {
+ final int fullLength = (int) reader.getUInt32();
+ final int chunkOffset = (int) reader.getUInt32();
+ if (extendedXMPBuffer == null) {
+ if (fullLength <= 0 || fullLength > MAX_EXTENDED) { // Tika OOM guard
+ return null;
+ }
+ extendedXMPBuffer = new byte[fullLength];
+ }
+ if (extendedXMPBuffer.length == fullLength) {
+ int copyLength = segmentLength - totalOffset;
+ if (chunkOffset >= 0 && chunkOffset <= extendedXMPBuffer.length - copyLength) {
+ System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset,
+ copyLength);
+ }
+ }
+ }
+ } catch (IOException ex) {
+ // best effort: keep whatever chunks were already assembled
+ }
+ }
+ return extendedXMPBuffer;
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java
index 9e2088c2562..f49f5947632 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java
@@ -16,8 +16,10 @@
*/
package org.apache.tika.parser.image;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@@ -27,6 +29,8 @@
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
@@ -48,7 +52,23 @@ public Set getSupportedTypes(ParseContext context) {
void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata metadata,
ParseContext parseContext)
throws IOException, SAXException, TikaException {
- new ImageMetadataExtractor(metadata).parseHeif(stream);
+ TemporaryResources tmp = new TemporaryResources();
+ try {
+ TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata);
+ File file = tis.getFile(); // spool so the file can be re-read below
+ // XMP first so it is canonical; metadata-extractor (EXIF/GPS) fills gaps.
+ // Locate the XMP item precisely via meta/iinf/iloc first, so an embedded resource's XMP
+ // (e.g. a motion-photo video in mdat) can't be attributed to the image; fall back to the
+ // byte scanner for packets not reachable through the boxes.
+ if (!HeifXmp.extract(file, metadata, parseContext)) {
+ ImageXmp.scanAndExtract(tis, metadata, parseContext);
+ }
+ try (InputStream heif = Files.newInputStream(file.toPath())) {
+ new ImageMetadataExtractor(metadata).parseHeif(heif);
+ }
+ } finally {
+ tmp.dispose();
+ }
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java
new file mode 100644
index 00000000000..3461b412657
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.drew.lang.SequentialByteArrayReader;
+import com.drew.metadata.heif.boxes.Box;
+import com.drew.metadata.heif.boxes.ItemInfoBox;
+import com.drew.metadata.heif.boxes.ItemInfoBox.ItemInfoEntry;
+import com.drew.metadata.heif.boxes.ItemLocationBox;
+import com.drew.metadata.heif.boxes.ItemLocationBox.Extent;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.xmp.XmpExtractor;
+
+/**
+ * HEIF/HEIC XMP fallback for packets that are not {@code }-wrapped (the byte
+ * scanner {@link ImageXmp#scanAndExtract} can't find those). XMP is a
+ * {@code mime}/{@code application/rdf+xml} item located via the ISO-BMFF
+ * {@code meta}/{@code iinf}/{@code iloc} boxes, parsed with metadata-extractor's box classes; only
+ * the top-level box walk is ours. No content-type getter exists, so a mime item is confirmed by
+ * sniffing its bytes for XMP markers.
+ */
+final class HeifXmp {
+
+ private HeifXmp() {
+ }
+
+ // 64 MB: guards against a corrupt extent length triggering a huge allocation.
+ private static final long MAX_ITEM = 64L * 1024 * 1024;
+
+ /** Locate the rdf+xml item and parse it; returns false if none was found. */
+ static boolean extract(File file, Metadata metadata, ParseContext context) {
+ byte[] xmp = locateQuietly(file);
+ if (xmp == null) {
+ return false;
+ }
+ try {
+ new XmpExtractor().extract(xmp, metadata, context);
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata); // bad XMP must not fail the parse
+ }
+ return true;
+ }
+
+ private static byte[] locateQuietly(File file) {
+ try {
+ return locate(file);
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | RuntimeException e) {
+ return null; // malformed / unreadable box structure -> no XMP, let EXIF proceed
+ }
+ }
+
+ private static byte[] locate(File file) throws IOException {
+ try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
+ long len = raf.length();
+ long[] meta = box(raf, 0, len, "meta");
+ if (meta == null) {
+ return null;
+ }
+ // meta is a FullBox: skip its 4-byte version/flags to reach the child boxes.
+ long childStart = meta[1] + 4;
+ long[] iinfAt = box(raf, childStart, meta[2], "iinf");
+ long[] ilocAt = box(raf, childStart, meta[2], "iloc");
+ if (iinfAt == null || ilocAt == null) {
+ return null;
+ }
+ ItemInfoBox iinf = parseIinf(readBytes(raf, iinfAt[0], (int) (iinfAt[2] - iinfAt[0])));
+ ItemLocationBox iloc = parseIloc(readBytes(raf, ilocAt[0], (int) (ilocAt[2] - ilocAt[0])));
+ return readXmpItem(raf, iinf, iloc, len);
+ }
+ }
+
+ /** First box of {@code type} within [start,end); returns {boxStart, payloadStart, boxEnd}. */
+ private static long[] box(RandomAccessFile raf, long start, long end, String type)
+ throws IOException {
+ long p = start;
+ while (p + 8 <= end) {
+ raf.seek(p);
+ long size = raf.readInt() & 0xffffffffL;
+ byte[] t = new byte[4];
+ raf.readFully(t);
+ long headerLen = 8;
+ if (size == 1) {
+ size = raf.readLong();
+ headerLen = 16;
+ } else if (size == 0) {
+ size = end - p;
+ }
+ if (size < headerLen || p + size > end) {
+ return null; // corrupt/truncated
+ }
+ if (type.equals(new String(t, US_ASCII))) {
+ return new long[]{p, p + headerLen, p + size};
+ }
+ p += size;
+ }
+ return null;
+ }
+
+ private static ItemInfoBox parseIinf(byte[] bytes) throws IOException {
+ SequentialByteArrayReader r = new SequentialByteArrayReader(bytes);
+ return new ItemInfoBox(r, new Box(r));
+ }
+
+ private static ItemLocationBox parseIloc(byte[] bytes) throws IOException {
+ SequentialByteArrayReader r = new SequentialByteArrayReader(bytes);
+ return new ItemLocationBox(r, new Box(r));
+ }
+
+ private static byte[] readXmpItem(RandomAccessFile raf, ItemInfoBox iinf, ItemLocationBox iloc,
+ long fileLen) throws IOException {
+ Map> byItem = new LinkedHashMap<>();
+ for (Extent e : iloc.getExtents()) {
+ byItem.computeIfAbsent(e.getItemId(), k -> new ArrayList<>()).add(e);
+ }
+ for (Map.Entry> item : byItem.entrySet()) {
+ ItemInfoEntry info = iinf.getEntry(item.getKey());
+ if (info == null || !"mime".equals(info.getItemType())) {
+ continue;
+ }
+ byte[] data = readExtents(raf, item.getValue(), fileLen);
+ if (data != null && looksLikeXmp(data)) {
+ return data;
+ }
+ }
+ return null;
+ }
+
+ private static byte[] readExtents(RandomAccessFile raf, List extents, long fileLen)
+ throws IOException {
+ // Extent.getOffset() is an absolute file offset (iloc construction_method 0, which every real
+ // XMP item uses). Methods 1/2 aren't resolved -- like metadata-extractor's Exif reader -- but
+ // that's a safe miss: a wrong offset fails the bounds check or yields bytes looksLikeXmp()
+ // rejects, so the item is skipped. Resolve the base offset if a real method-1/2 file appears.
+ long total = 0;
+ for (Extent e : extents) {
+ if (e.getOffset() < 0 || e.getLength() < 0 || e.getOffset() + e.getLength() > fileLen) {
+ return null;
+ }
+ total += e.getLength();
+ if (total > MAX_ITEM) {
+ return null;
+ }
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
+ for (Extent e : extents) {
+ out.write(readBytes(raf, e.getOffset(), (int) e.getLength()));
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] readBytes(RandomAccessFile raf, long pos, int len) throws IOException {
+ byte[] b = new byte[len];
+ raf.seek(pos);
+ raf.readFully(b);
+ return b;
+ }
+
+ private static boolean looksLikeXmp(byte[] data) {
+ String head = new String(data, 0, Math.min(data.length, 8192), US_ASCII);
+ return head.contains("xmpmeta") || head.contains("rdf:RDF") || head.contains("xpacket");
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
index 900c4d3f558..12527c47345 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java
@@ -22,19 +22,19 @@
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.SimpleDateFormat;
+import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
+import java.util.List;
import java.util.Locale;
-import java.util.Map;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import com.adobe.internal.xmp.XMPException;
-import com.adobe.internal.xmp.XMPMetaFactory;
import com.drew.imaging.heif.HeifMetadataReader;
import com.drew.imaging.jpeg.JpegMetadataReader;
import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentMetadataReader;
import com.drew.imaging.riff.RiffProcessingException;
import com.drew.imaging.tiff.TiffMetadataReader;
import com.drew.imaging.tiff.TiffProcessingException;
@@ -45,6 +45,7 @@
import com.drew.metadata.Directory;
import com.drew.metadata.MetadataException;
import com.drew.metadata.Tag;
+import com.drew.metadata.adobe.AdobeJpegReader;
import com.drew.metadata.exif.ExifDirectoryBase;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifReader;
@@ -52,14 +53,20 @@
import com.drew.metadata.exif.ExifThumbnailDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.icc.IccDirectory;
+import com.drew.metadata.icc.IccReader;
import com.drew.metadata.iptc.IptcDirectory;
+import com.drew.metadata.iptc.IptcReader;
+import com.drew.metadata.jfif.JfifReader;
+import com.drew.metadata.jfxx.JfxxReader;
import com.drew.metadata.jpeg.JpegCommentDirectory;
+import com.drew.metadata.jpeg.JpegCommentReader;
+import com.drew.metadata.jpeg.JpegDhtReader;
import com.drew.metadata.jpeg.JpegDirectory;
-import com.drew.metadata.xmp.XmpDirectory;
+import com.drew.metadata.jpeg.JpegDnlReader;
+import com.drew.metadata.jpeg.JpegReader;
+import com.drew.metadata.photoshop.DuckyReader;
+import com.drew.metadata.photoshop.PhotoshopReader;
import org.apache.commons.io.IOUtils;
-import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import org.apache.tika.exception.TikaException;
@@ -69,9 +76,6 @@
import org.apache.tika.metadata.Property;
import org.apache.tika.metadata.TIFF;
import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.xmp.JempboxExtractor;
-import org.apache.tika.utils.XMLReaderUtils;
/**
* Uses the Metadata Extractor library
@@ -83,7 +87,6 @@
public class ImageMetadataExtractor {
//TODO: add this to the signatures from the actual parse
- private static final ParseContext EMPTY_PARSE_CONTEXT = new ParseContext();
private static final String GEO_DECIMAL_FORMAT_STRING = "#.######";
// 6 dp seems to be reasonable
@@ -99,7 +102,7 @@ public class ImageMetadataExtractor {
public ImageMetadataExtractor(Metadata metadata) {
this(metadata, new CopyUnknownFieldsHandler(), new TiffPageNumberHandler(),
new JpegCommentHandler(), new ExifHandler(), new DimensionsHandler(),
- new GeotagHandler(), new IptcHandler(), new XmpHandler());
+ new GeotagHandler(), new IptcHandler());
}
/**
@@ -121,9 +124,16 @@ private static String trimPixels(String s) {
return s;
}
+ // ALL_READERS minus XmpReader: Tika parses XMP itself, avoiding a double XMP parse.
+ private static final List JPEG_READERS_NO_XMP = Arrays.asList(
+ new JpegReader(), new JpegCommentReader(), new JfifReader(), new JfxxReader(),
+ new ExifReader(), new IccReader(), new PhotoshopReader(), new DuckyReader(),
+ new IptcReader(), new AdobeJpegReader(), new JpegDhtReader(), new JpegDnlReader());
+
public void parseJpeg(File file) throws IOException, SAXException, TikaException {
try {
- com.drew.metadata.Metadata jpegMetadata = JpegMetadataReader.readMetadata(file);
+ com.drew.metadata.Metadata jpegMetadata =
+ JpegMetadataReader.readMetadata(file, JPEG_READERS_NO_XMP);
handle(jpegMetadata);
} catch (JpegProcessingException | MetadataException e) {
throw new TikaException("Can't read JPEG metadata", e);
@@ -191,22 +201,6 @@ public void parseRawExif(byte[] exifData) throws IOException, SAXException, Tika
}
}
- public void parseRawXMP(byte[] xmpData) throws IOException, SAXException, TikaException {
- XMPMetadata xmp = null;
- try (InputStream decoded = UnsynchronizedByteArrayInputStream.builder().setByteArray(xmpData).get()) {
- Document dom = XMLReaderUtils.buildDOM(decoded, EMPTY_PARSE_CONTEXT);
- if (dom != null) {
- xmp = new XMPMetadata(dom);
- }
- } catch (IOException | SAXException e) {
- //
- }
- if (xmp != null) {
- JempboxExtractor.extractDublinCore(xmp, metadata);
- JempboxExtractor.extractXMPMM(xmp, metadata);
- }
-
- }
/**
* Copies extracted tags to tika metadata using registered handlers.
@@ -310,55 +304,6 @@ public void handle(Directory directory, Metadata metadata) throws MetadataExcept
}
}
- /**
- * Copies the XMP properties parsed by Metadata Extractor into the metadata,
- * keyed by their {@code prefix:name} path. The other handlers copy a
- * directory's tags, but XMP keeps its properties in a separate map
- * ({@link XmpDirectory#getXmpProperties()}), so without this they are lost.
- * A property is skipped when its key is already set or matches a known Tika
- * field, so normalized values from other handlers are not overwritten.
- */
- static class XmpHandler implements DirectoryHandler {
-
- static {
- // XMPCore's namespace registry is process-global and keeps the first
- // prefix it sees for a URI. Pin canonical prefixes so keys stay stable
- // (files use both Camera and GCamera for the Google photo namespace).
- // https://developer.android.com/media/platform/motion-photo-format
- try {
- XMPMetaFactory.getSchemaRegistry()
- .registerNamespace("http://ns.google.com/photos/1.0/camera/", "Camera");
- XMPMetaFactory.getSchemaRegistry()
- .registerNamespace("http://ns.google.com/photos/1.0/container/", "Container");
- XMPMetaFactory.getSchemaRegistry()
- .registerNamespace("http://ns.google.com/photos/1.0/container/item/", "Item");
- } catch (XMPException e) {
- // Constant, valid URIs, so this cannot throw. A broken registration
- // would make the keys non-deterministic (parse-order dependent), which
- // MotionPhotoXmpTest catches in CI; rethrowing from a static initializer
- // would break all image parsing, so it is swallowed.
- }
- }
-
- public boolean supports(Class extends Directory> directoryType) {
- return XmpDirectory.class.isAssignableFrom(directoryType);
- }
-
- public void handle(Directory directory, Metadata metadata) throws MetadataException {
- Map properties = ((XmpDirectory) directory).getXmpProperties();
- if (properties == null) {
- return;
- }
- for (Map.Entry property : properties.entrySet()) {
- String name = property.getKey();
- String value = property.getValue();
- if (value != null && metadata.get(name) == null
- && !MetadataFields.isMetadataField(name)) {
- metadata.set(name, value);
- }
- }
- }
- }
static class TiffPageNumberHandler implements DirectoryHandler {
public boolean supports(Class extends Directory> directoryType) {
@@ -651,19 +596,25 @@ public void handle(Directory directory, Metadata metadata) throws MetadataExcept
metadata.add(TikaCoreProperties.SUBJECT, k);
}
}
- if (directory.containsTag(IptcDirectory.TAG_HEADLINE)) {
- metadata.set(TikaCoreProperties.TITLE,
- directory.getString(IptcDirectory.TAG_HEADLINE));
- } else if (directory.containsTag(IptcDirectory.TAG_OBJECT_NAME)) {
- metadata.set(TikaCoreProperties.TITLE,
- directory.getString(IptcDirectory.TAG_OBJECT_NAME));
+ // IPTC fallback: XMP is canonical, so only fill what XMP left unset (XMP wins).
+ if (metadata.get(TikaCoreProperties.TITLE) == null) {
+ if (directory.containsTag(IptcDirectory.TAG_HEADLINE)) {
+ metadata.set(TikaCoreProperties.TITLE,
+ directory.getString(IptcDirectory.TAG_HEADLINE));
+ } else if (directory.containsTag(IptcDirectory.TAG_OBJECT_NAME)) {
+ metadata.set(TikaCoreProperties.TITLE,
+ directory.getString(IptcDirectory.TAG_OBJECT_NAME));
+ }
}
if (directory.containsTag(IptcDirectory.TAG_BY_LINE)) {
- metadata.set(TikaCoreProperties.CREATOR,
- directory.getString(IptcDirectory.TAG_BY_LINE));
+ if (metadata.get(TikaCoreProperties.CREATOR) == null) {
+ metadata.set(TikaCoreProperties.CREATOR,
+ directory.getString(IptcDirectory.TAG_BY_LINE));
+ }
metadata.set(IPTC.CREATOR, directory.getString(IptcDirectory.TAG_BY_LINE));
}
- if (directory.containsTag(IptcDirectory.TAG_CAPTION)) {
+ if (directory.containsTag(IptcDirectory.TAG_CAPTION)
+ && metadata.get(TikaCoreProperties.DESCRIPTION) == null) {
metadata.set(TikaCoreProperties.DESCRIPTION,
// Looks like metadata extractor returns IPTC newlines
// as a single carriage return,
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
new file mode 100644
index 00000000000..af88c136592
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentData;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.imaging.jpeg.JpegSegmentType;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.xmp.XMPPacketScanner;
+import org.apache.tika.parser.xmp.XmpExtractor;
+
+/**
+ * Image XMP via the shared {@link XmpExtractor}. XMP is auxiliary: a bad packet is recorded and
+ * swallowed so it never fails the image parse (matches the PDF module).
+ */
+final class ImageXmp {
+
+ private ImageXmp() {
+ }
+
+ /**
+ * Scan an {@code }-wrapped packet out of a stream (TIFF/JXL/PSD/HEIF) and parse
+ * it. Returns true if a packet was found, so callers can fall back to another locator.
+ */
+ static boolean scanAndExtract(InputStream stream, Metadata metadata, ParseContext context) {
+ try {
+ UnsynchronizedByteArrayOutputStream out =
+ UnsynchronizedByteArrayOutputStream.builder().get();
+ if (new XMPPacketScanner().parse(stream, out)) {
+ try (InputStream packet = out.toInputStream()) {
+ new XmpExtractor().extract(packet, metadata, context);
+ }
+ return true;
+ }
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata);
+ }
+ return false;
+ }
+
+ /** Parse an already-isolated raw XMP packet (BPG). */
+ static void extractRaw(byte[] xmp, Metadata metadata, ParseContext context) {
+ try {
+ new XmpExtractor().extract(xmp, metadata, context);
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata);
+ }
+ }
+
+ /** JPEG: read APP1 segments, reassemble Extended XMP, parse each resulting packet. */
+ static void extractJpeg(File file, Metadata metadata, ParseContext context) {
+ try {
+ Iterable app1;
+ try {
+ JpegSegmentData data = JpegSegmentReader.readSegments(file,
+ Collections.singletonList(JpegSegmentType.APP1));
+ app1 = data.getSegments(JpegSegmentType.APP1);
+ } catch (JpegProcessingException e) {
+ return; // not a decodable jpeg segment structure
+ }
+ if (app1 == null) {
+ return;
+ }
+ for (byte[] packet : ExtendedXmp.assemble(app1)) {
+ new XmpExtractor().extract(packet, metadata, context);
+ }
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata);
+ }
+ }
+
+ /** WebP: pull the raw packet out of the RIFF {@code "XMP "} chunk and parse it. */
+ static void extractWebp(File file, Metadata metadata, ParseContext context) {
+ try {
+ byte[] xmp = readRiffChunk(file, "XMP ");
+ if (xmp != null) {
+ new XmpExtractor().extract(xmp, metadata, context);
+ }
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata);
+ }
+ }
+
+ // 64 MB: guards against a corrupt chunk size triggering a huge allocation.
+ private static final long MAX_CHUNK = 64L * 1024 * 1024;
+
+ /** Return the payload of the first top-level RIFF chunk with the given FourCC, or null. */
+ private static byte[] readRiffChunk(File file, String fourCC) throws IOException {
+ try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
+ byte[] head = new byte[12];
+ if (IOUtils.read(in, head, 0, 12) < 12 || head[0] != 'R' || head[1] != 'I' ||
+ head[2] != 'F' || head[3] != 'F' || head[8] != 'W' || head[9] != 'E' ||
+ head[10] != 'B' || head[11] != 'P') {
+ return null;
+ }
+ byte[] ch = new byte[8];
+ while (IOUtils.read(in, ch, 0, 8) == 8) {
+ long size = (ch[4] & 0xffL) | (ch[5] & 0xffL) << 8 |
+ (ch[6] & 0xffL) << 16 | (ch[7] & 0xffL) << 24;
+ if (fourCC.equals(new String(ch, 0, 4, StandardCharsets.US_ASCII))) {
+ if (size > MAX_CHUNK) {
+ return null; // target chunk too large to allocate
+ }
+ byte[] data = new byte[(int) size];
+ return IOUtils.read(in, data, 0, data.length) == data.length ? data : null;
+ }
+ // a large foreign chunk before "XMP " must be skipped, not abort the scan
+ IOUtils.skipFully(in, size + (size & 1L)); // RIFF pads chunks to even length
+ }
+ }
+ return null;
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java
index 1cb8d1d3c9a..e9d75088c97 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java
@@ -30,7 +30,6 @@
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
-import org.apache.tika.parser.xmp.JempboxExtractor;
/**
* Tries to scrape XMP out of JXL
@@ -49,7 +48,6 @@ public Set getSupportedTypes(ParseContext context) {
@Override
public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
ParseContext context) throws IOException, SAXException, TikaException {
- JempboxExtractor jempboxExtractor = new JempboxExtractor(metadata);
- jempboxExtractor.parse(tis);
+ ImageXmp.scanAndExtract(tis, metadata, context);
}
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
index 2f3cc714a5e..a96311857f5 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java
@@ -31,7 +31,6 @@
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.xmp.JempboxExtractor;
@TikaComponent
public class JpegParser extends AbstractImageParser {
@@ -55,8 +54,9 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata
TemporaryResources tmp = new TemporaryResources();
try {
TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata);
+ // XMP first so it is canonical; the metadata-extractor handlers (IPTC/EXIF) fill gaps.
+ ImageXmp.extractJpeg(tis.getFile(), metadata, parseContext);
new ImageMetadataExtractor(metadata).parseJpeg(tis.getFile());
- new JempboxExtractor(metadata).parse(tis);
} finally {
tmp.dispose();
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
index 61527b77de2..3ca28df23fb 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java
@@ -44,7 +44,6 @@
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
-import org.apache.tika.parser.xmp.JempboxExtractor;
import org.apache.tika.sax.XHTMLContentHandler;
/**
@@ -159,8 +158,9 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
} else if (rb.id == ResourceBlock.ID_XMP) {
//if there are multiple xmps in a file, this will
//overwrite the data from the earlier xmp
- JempboxExtractor ex = new JempboxExtractor(metadata);
- ex.parse(UnsynchronizedByteArrayInputStream.builder().setByteArray(rb.data).get());
+ ImageXmp.scanAndExtract(
+ UnsynchronizedByteArrayInputStream.builder().setByteArray(rb.data).get(),
+ metadata, context);
}
blocks++;
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
index 2ac2e629757..65664bff10f 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java
@@ -31,7 +31,6 @@
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.xmp.JempboxExtractor;
@TikaComponent
public class TiffParser extends AbstractImageParser {
@@ -55,8 +54,10 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata
TemporaryResources tmp = new TemporaryResources();
try {
TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata);
+ tis.getFile(); // spool so tis is fully re-readable below
+ // XMP first so it is canonical; metadata-extractor (IPTC/EXIF) fills gaps.
+ ImageXmp.scanAndExtract(tis, metadata, parseContext);
new ImageMetadataExtractor(metadata).parseTiff(tis.getFile());
- new JempboxExtractor(metadata).parse(tis);
} finally {
tmp.dispose();
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
index 64e9ed64136..1af3b5e5a34 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java
@@ -49,6 +49,8 @@ public Set getSupportedTypes(ParseContext context) {
public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
ParseContext context) throws IOException, SAXException, TikaException {
+ // XMP first (canonical), then EXIF/etc. from metadata-extractor as fallback.
+ ImageXmp.extractWebp(tis.getFile(), metadata, context);
new ImageMetadataExtractor(metadata).parseWebP(tis.getFile());
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java
new file mode 100644
index 00000000000..7cdb7b6edae
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class ExtendedXmpTest {
+
+ private static final String STD = "http://ns.adobe.com/xap/1.0/\u0000";
+ private static final String EXT = "http://ns.adobe.com/xmp/extension/\u0000";
+ private static final String GUID = "0123456789ABCDEF0123456789ABCDEF";
+
+ private static byte[] concat(byte[]... parts) throws Exception {
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ for (byte[] p : parts) {
+ b.write(p);
+ }
+ return b.toByteArray();
+ }
+
+ private static byte[] be(int v) {
+ return new byte[]{(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
+ }
+
+ /** Standard packet + two out-of-order extension chunks reassemble to the full extended packet. */
+ @Test
+ public void testReassemblesExtended() throws Exception {
+ byte[] std = ("").getBytes(US_ASCII);
+ byte[] full = "EXTENDED-XMP-PAYLOAD-DATA-1234567890".getBytes(US_ASCII);
+ int split = 20;
+ byte[] c1 = Arrays.copyOfRange(full, 0, split);
+ byte[] c2 = Arrays.copyOfRange(full, split, full.length);
+
+ byte[] stdSeg = concat(STD.getBytes(US_ASCII), std);
+ byte[] ext1 = concat(EXT.getBytes(US_ASCII), GUID.getBytes(US_ASCII), be(full.length), be(0), c1);
+ byte[] ext2 =
+ concat(EXT.getBytes(US_ASCII), GUID.getBytes(US_ASCII), be(full.length), be(split), c2);
+
+ // feed the extension chunks out of order to prove offset-based placement
+ List packets = ExtendedXmp.assemble(Arrays.asList(stdSeg, ext2, ext1));
+ assertEquals(2, packets.size());
+ assertArrayEquals(std, packets.get(0));
+ assertArrayEquals(full, packets.get(1));
+ }
+
+ /** No HasExtendedXMP GUID -> just the standard packet, no extended part. */
+ @Test
+ public void testNoExtended() throws Exception {
+ byte[] std = "plain".getBytes(US_ASCII);
+ List packets = ExtendedXmp.assemble(Arrays.asList(concat(STD.getBytes(US_ASCII), std)));
+ assertEquals(1, packets.size());
+ assertArrayEquals(std, packets.get(0));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java
new file mode 100644
index 00000000000..ba6d805b6ff
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * HEIF/HEIC XMP was always dropped — metadata-extractor never read it. Stored as an
+ * {@code application/rdf+xml} item but usually {@code }-wrapped, so the shared packet
+ * scanner catches it (same path as TIFF/PSD/JXL). Fixture is a hand-built HEIC, no corpus binary.
+ */
+public class HeicXmpTest {
+
+ private static final String XMP =
+ ""
+ + ""
+ + ""
+ + ""
+ + "HEIC XMP Title"
+ + ""
+ + "Jane Photographer"
+ + "";
+
+ /** Same content but with NO xpacket wrapper -> the scanner misses it, the item locator wins. */
+ private static final String BARE_XMP =
+ ""
+ + ""
+ + ""
+ + "Bare HEIC Title"
+ + ""
+ + "Jane Photographer"
+ + "";
+
+ private static byte[] u32(int v) {
+ return new byte[]{(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
+ }
+
+ private static byte[] u16(int v) {
+ return new byte[]{(byte) (v >>> 8), (byte) v};
+ }
+
+ private static byte[] concat(byte[]... parts) {
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ for (byte[] p : parts) {
+ b.writeBytes(p);
+ }
+ return b.toByteArray();
+ }
+
+ private static byte[] box(String type, byte[] payload) {
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ b.writeBytes(u32(8 + payload.length));
+ b.writeBytes(type.getBytes(US_ASCII));
+ b.writeBytes(payload);
+ return b.toByteArray();
+ }
+
+ private static byte[] fullBox(String type, byte[] payload) {
+ return fullBox(type, 0, payload);
+ }
+
+ private static byte[] fullBox(String type, int version, byte[] payload) {
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ b.write(version);
+ b.writeBytes(new byte[3]); // flags
+ b.writeBytes(payload);
+ return box(type, b.toByteArray());
+ }
+
+ /** ftyp(heic) + minimal meta(hdlr=pict) + mdat holding the XMP packet. */
+ private static byte[] heicWithXmp() {
+ ByteArrayOutputStream ftypPayload = new ByteArrayOutputStream();
+ ftypPayload.writeBytes("heic".getBytes(US_ASCII)); // major brand
+ ftypPayload.writeBytes(u32(0)); // minor version
+ ftypPayload.writeBytes("mif1".getBytes(US_ASCII)); // compatible brands
+ ftypPayload.writeBytes("heic".getBytes(US_ASCII));
+
+ ByteArrayOutputStream hdlrPayload = new ByteArrayOutputStream();
+ hdlrPayload.writeBytes(u32(0)); // pre_defined
+ hdlrPayload.writeBytes("pict".getBytes(US_ASCII)); // handler_type
+ hdlrPayload.writeBytes(new byte[13]); // reserved[3] + empty name
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.writeBytes(box("ftyp", ftypPayload.toByteArray()));
+ out.writeBytes(fullBox("meta", fullBox("hdlr", hdlrPayload.toByteArray())));
+ out.writeBytes(box("mdat", XMP.getBytes(UTF_8)));
+ return out.toByteArray();
+ }
+
+ /**
+ * ftyp(heic) + meta(hdlr, iinf declaring a mime/application/rdf+xml item, iloc pointing at
+ * mdat) + mdat holding a bare (non-xpacket) XMP packet. Exercises the iinf/iloc item locator.
+ */
+ private static byte[] bareItemHeic() {
+ byte[] xmp = BARE_XMP.getBytes(UTF_8);
+ byte[] ftyp = box("ftyp", concat("heic".getBytes(US_ASCII), u32(0),
+ "mif1".getBytes(US_ASCII), "heic".getBytes(US_ASCII)));
+ byte[] hdlr = fullBox("hdlr", concat(u32(0), "pict".getBytes(US_ASCII), new byte[13]));
+ // infe v2: item_ID=1, protection=0, type=mime, empty name, content_type + null terminator
+ byte[] infe = fullBox("infe", 2, concat(u16(1), u16(0), "mime".getBytes(US_ASCII),
+ new byte[]{0}, "application/rdf+xml".getBytes(US_ASCII), new byte[]{0}));
+ byte[] iinf = fullBox("iinf", concat(u16(1), infe));
+
+ // The extent offset is a fixed-width field, so meta's size does not depend on its value:
+ // build once with a placeholder to learn meta's length, then again with the real offset.
+ int metaLen = fullBox("meta", concat(hdlr, iinf, iloc(0, xmp.length))).length;
+ int xmpOffset = ftyp.length + metaLen + 8; // + mdat header
+ byte[] meta = fullBox("meta", concat(hdlr, iinf, iloc(xmpOffset, xmp.length)));
+ return concat(ftyp, meta, box("mdat", xmp));
+ }
+
+ /** iloc v0: offset/length size 4, no base offset; one item, one extent. */
+ private static byte[] iloc(int offset, int length) {
+ return fullBox("iloc", concat(
+ new byte[]{0x44, 0x00}, // offsetSize=4, lengthSize=4; baseOffsetSize=0
+ u16(1), // item_count
+ u16(1), u16(0), u16(1), // item_ID, data_reference_index, extent_count
+ u32(offset), u32(length)));
+ }
+
+ @Test
+ public void testHeicXmpIsExtracted(@TempDir Path tmp) throws Exception {
+ Path file = tmp.resolve("xmp.heic");
+ Files.write(file, heicWithXmp());
+
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "image/heic");
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
+ }
+
+ assertEquals("HEIC XMP Title", metadata.get(TikaCoreProperties.TITLE));
+ assertEquals("HEIC XMP Title", metadata.get("dc:title"));
+ assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR));
+ assertEquals("Jane Photographer", metadata.get("dc:creator"));
+ }
+
+ /** XMP with no xpacket wrapper is found by locating the rdf+xml item via meta/iinf/iloc. */
+ @Test
+ public void testBareRdfItemXmpIsExtracted(@TempDir Path tmp) throws Exception {
+ Path file = tmp.resolve("bare.heic");
+ Files.write(file, bareItemHeic());
+
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "image/heic");
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
+ }
+
+ assertEquals("Bare HEIC Title", metadata.get(TikaCoreProperties.TITLE));
+ assertEquals("Bare HEIC Title", metadata.get("dc:title"));
+ assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java
index 1b92fbb00d0..ece85fffe59 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java
@@ -203,8 +203,9 @@ public void testJPEGTitleAndDescription() throws Exception {
assertEquals("2.8", metadata.get(Metadata.F_NUMBER));
assertEquals("4.6", metadata.get(Metadata.FOCAL_LENGTH));
assertEquals("114", metadata.get(Metadata.ISO_SPEED_RATINGS));
- assertEquals(null, metadata.get(Metadata.EQUIPMENT_MAKE));
- assertEquals(null, metadata.get(Metadata.EQUIPMENT_MODEL));
+ // Make/Model now come from the promoted XMP tiff: schema; this file's binary EXIF has none.
+ assertEquals("Nokia", metadata.get(Metadata.EQUIPMENT_MAKE));
+ assertEquals("N78", metadata.get(Metadata.EQUIPMENT_MODEL));
assertEquals(null, metadata.get(Metadata.SOFTWARE));
assertEquals("1", metadata.get(Metadata.ORIENTATION)); // Not present
assertEquals("300.0", metadata.get(Metadata.RESOLUTION_HORIZONTAL));
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java
index dd98659c148..13ab1e86dde 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java
@@ -24,6 +24,7 @@
import org.apache.tika.TikaTest;
import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Google;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
@@ -47,10 +48,14 @@ public void testMotionPhotoXmpIsExposed() throws Exception {
assertEquals("1", metadata.get("Camera:MotionPhoto"));
assertEquals("1", metadata.get("Camera:MotionPhotoVersion"));
assertEquals("500000", metadata.get("Camera:MotionPhotoPresentationTimestampUs"));
+ // also reachable through the declared, typed property
+ assertEquals("1", metadata.get(Google.MOTION_PHOTO));
// The embedded video item (its byte length lets a client range-fetch the
// video without downloading the whole file) is exposed too.
- assertEquals("MotionPhoto", metadata.get("Container:Directory[2]/Item:Semantic"));
- assertEquals("122562", metadata.get("Container:Directory[2]/Item:Length"));
+ assertEquals("MotionPhoto",
+ metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic"));
+ assertEquals("122562",
+ metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Length"));
}
/** Keys use the canonical prefix even when the file declares another (GCamera). */
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java
new file mode 100644
index 00000000000..30b03a3c1b5
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * WebP stores XMP in a RIFF {@code "XMP "} chunk. Tika parses it with the shared
+ * {@link org.apache.tika.parser.xmp.XmpExtractor}; regression guard for TIKA where the
+ * WebP path dropped XMP entirely (no in-repo WebP carries an XMP packet).
+ */
+public class WebPXmpTest {
+
+ private static final String XMP =
+ ""
+ + ""
+ + ""
+ + ""
+ + "WebP XMP Title"
+ + ""
+ + "Jane Photographer"
+ + "";
+
+ private static void chunk(ByteArrayOutputStream riff, String fourCC, byte[] data) {
+ riff.writeBytes(fourCC.getBytes(US_ASCII));
+ riff.writeBytes(le(data.length));
+ riff.writeBytes(data);
+ if ((data.length & 1) == 1) {
+ riff.write(0); // RIFF pads chunks to even length
+ }
+ }
+
+ private static byte[] le(int v) {
+ return new byte[]{(byte) v, (byte) (v >>> 8), (byte) (v >>> 16), (byte) (v >>> 24)};
+ }
+
+ private static byte[] webpWithXmp() {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ body.writeBytes("WEBP".getBytes(US_ASCII));
+ // VP8X extended-format header with the XMP flag (0x04) set
+ chunk(body, "VP8X", new byte[]{0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+ chunk(body, "VP8 ", new byte[16]); // dummy bitstream; content is irrelevant to metadata
+ chunk(body, "XMP ", XMP.getBytes(UTF_8));
+ ByteArrayOutputStream riff = new ByteArrayOutputStream();
+ riff.writeBytes("RIFF".getBytes(US_ASCII));
+ riff.writeBytes(le(body.size()));
+ riff.writeBytes(body.toByteArray());
+ return riff.toByteArray();
+ }
+
+ @Test
+ public void testWebpXmpIsExtracted(@TempDir Path tmp) throws Exception {
+ Path file = tmp.resolve("xmp.webp");
+ Files.write(file, webpWithXmp());
+
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "image/webp");
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ new WebPParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
+ }
+
+ assertEquals("WebP XMP Title", metadata.get(TikaCoreProperties.TITLE));
+ assertEquals("WebP XMP Title", metadata.get("dc:title"));
+ assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR));
+ assertEquals("Jane Photographer", metadata.get("dc:creator"));
+ }
+
+ /** A malformed XMP packet is recorded, not thrown: the rest of the parse still succeeds. */
+ @Test
+ public void testMalformedXmpIsNonFatal(@TempDir Path tmp) throws Exception {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ body.write("WEBP".getBytes(US_ASCII));
+ chunk(body, "VP8X", new byte[]{0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+ chunk(body, "VP8 ", new byte[16]);
+ chunk(body, "XMP ", "unclosed".getBytes(UTF_8));
+ ByteArrayOutputStream riff = new ByteArrayOutputStream();
+ riff.write("RIFF".getBytes(US_ASCII));
+ riff.writeBytes(le(body.size()));
+ riff.writeBytes(body.toByteArray());
+ Path file = tmp.resolve("bad.webp");
+ Files.write(file, riff.toByteArray());
+
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "image/webp");
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ new WebPParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
+ }
+ assertNull(metadata.get(TikaCoreProperties.TITLE));
+ assertNotNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java
index 10261e90548..168471672c0 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java
@@ -33,13 +33,14 @@
import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Office;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
-import org.apache.tika.parser.xmp.XMPMetadataExtractor;
+import org.apache.tika.parser.xmp.XmpExtractor;
import org.apache.tika.sax.EndDocumentShieldingContentHandler;
import org.apache.tika.sax.XHTMLContentHandler;
@@ -166,7 +167,13 @@ private void handleZipEntry(ZipEntry entry, InputStream zip, Metadata metadata,
String type = IOUtils.toString(zip, UTF_8);
metadata.set(Metadata.CONTENT_TYPE, type);
} else if (entry.getName().equals("META-INF/metadata.xml")) {
- XMPMetadataExtractor.parse(zip, metadata);
+ try {
+ new XmpExtractor().extract(zip, metadata, context);
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata); // malformed XMP must not fail the IDML
+ }
} else if (entry.getName().contains("MasterSpreads")) {
Metadata embeddedMeta = Metadata.newInstance(context);
ContentAndMetadataExtractor.extract(zip, handler, embeddedMeta, context);
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml
index 18918f43e04..b4e27e101ff 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml
@@ -52,11 +52,6 @@
-
- org.apache.pdfbox
- jempbox
- ${jempbox.version}
-
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index 1d2b5adf061..a2ccb629e62 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -81,7 +81,6 @@
import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
import org.apache.tika.parser.pdf.updates.StartXRefOffset;
import org.apache.tika.parser.pdf.updates.StartXRefScanner;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaIllustrator;
import org.apache.tika.renderer.PageRangeRequest;
import org.apache.tika.renderer.RenderResult;
import org.apache.tika.renderer.RenderResults;
@@ -374,7 +373,7 @@ private void checkIllustrator(final PDDocument pdfDocument, Metadata metadata) {
if (privateDict == null) {
return;
}
- metadata.set(Metadata.CONTENT_TYPE, XMPSchemaIllustrator.ILLUSTRATOR);
+ metadata.set(Metadata.CONTENT_TYPE, MediaType.application("illustrator").toString());
//TODO -- consider parsing the metadata
//COSStream aiMetaData = privateDict.getCOSStream(COSName.AI_META_DATA);
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java
index ffe54748999..b49ccde4bcf 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java
@@ -19,344 +19,73 @@
import java.io.IOException;
import java.io.InputStream;
import java.util.Calendar;
-import java.util.List;
import java.util.Locale;
import org.apache.commons.io.IOUtils;
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.apache.jempbox.xmp.XMPSchemaBasic;
-import org.apache.jempbox.xmp.XMPSchemaDublinCore;
-import org.apache.jempbox.xmp.XMPSchemaPDF;
-import org.apache.jempbox.xmp.pdfa.XMPSchemaPDFAId;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
-import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
-import org.apache.tika.metadata.DublinCore;
import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.Office;
import org.apache.tika.metadata.PDF;
import org.apache.tika.metadata.Property;
-import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.metadata.XMP;
-import org.apache.tika.metadata.XMPDC;
-import org.apache.tika.metadata.XMPPDF;
import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaIllustrator;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFUA;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFVT;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFX;
-import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFXId;
-import org.apache.tika.parser.xmp.JempboxExtractor;
-import org.apache.tika.utils.ExceptionUtils;
+import org.apache.tika.parser.xmp.XmpExtractor;
import org.apache.tika.utils.StringUtils;
-import org.apache.tika.utils.XMLReaderUtils;
public class PDMetadataExtractor {
+ /** Parse a PDF's XMP packet via the shared {@link XmpExtractor}. */
public static void extract(PDMetadata pdMetadata, Metadata metadata, ParseContext context) {
if (pdMetadata == null) {
metadata.set(PDF.HAS_XMP, "false");
return;
}
- //this file has XMP...
- //whether or not it is readable or throws an exception is another story...
metadata.set(PDF.HAS_XMP, "true");
- //now go for the XMP
- Document dom = loadDOM(pdMetadata, metadata, context);
- if (dom == null) {
- return;
- }
- XMPMetadata xmp = new XMPMetadata(dom);
- extract(xmp, metadata, context);
- }
-
- public static void extract(XMPMetadata xmp, Metadata metadata, ParseContext context) {
- extractBasic(xmp, metadata);
- extractPDF(xmp, metadata);
- extractDublinCore(xmp, metadata);
- JempboxExtractor.extractXMPMM(xmp, metadata);
- extractPDFA(xmp, metadata);
- extractPDFX(xmp, metadata);
- extractPDFVT(xmp, metadata);
- extractPDFUA(xmp, metadata);
- extractIllustrator(xmp, metadata);
- }
-
- private static void extractIllustrator(XMPMetadata xmp, Metadata metadata) {
- xmp.addXMLNSMapping(XMPSchemaIllustrator.NAMESPACE_URI, XMPSchemaIllustrator.class);
- XMPSchemaIllustrator schema = null;
- try {
- schema = (XMPSchemaIllustrator) xmp.getSchemaByClass(XMPSchemaIllustrator.class);
- } catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
-
- if (schema == null) {
- return;
- }
- String type = schema.getType();
- if (! StringUtils.isBlank(type)) {
- metadata.set(PDF.ILLUSTRATOR_TYPE, type);
- }
- }
-
- private static void extractDublinCore(XMPMetadata xmp, Metadata metadata) {
- XMPSchemaDublinCore dcSchema = null;
- try {
- dcSchema = xmp.getDublinCoreSchema();
- } catch (IOException e) {
- //swallow
- }
- if (dcSchema == null) {
- return;
- }
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.CONTRIBUTOR.getName(), TikaCoreProperties.CONTRIBUTOR, XMPDC.CONTRIBUTOR);
- extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.COVERAGE.getName(), TikaCoreProperties.COVERAGE, XMPDC.COVERAGE);
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.CREATOR.getName(), TikaCoreProperties.CREATOR, XMPDC.CREATOR);
-
- extractDublinCoreListItems(metadata, dcSchema, XMPDC.DATE.getName(), XMPDC.DATE);
- extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.DESCRIPTION.getName(), TikaCoreProperties.DESCRIPTION, XMPDC.DESCRIPTION);
- extractDublinCoreListItems(metadata, dcSchema, XMPDC.FORMAT.getName(), XMPDC.FORMAT);
- extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.IDENTIFIER.getName(), TikaCoreProperties.IDENTIFIER, XMPDC.IDENTIFIER);
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.LANGUAGE.getName(), TikaCoreProperties.LANGUAGE, XMPDC.LANGUAGE);
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.PUBLISHER.getName(), TikaCoreProperties.PUBLISHER, XMPDC.PUBLISHER);
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.RELATION.getName(), TikaCoreProperties.RELATION, XMPDC.RELATION);
- extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.RIGHTS.getName(), TikaCoreProperties.RIGHTS, XMPDC.RIGHTS);
- extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.SOURCE.getName(), TikaCoreProperties.SOURCE, XMPDC.SOURCE);
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.SUBJECT.getName(), TikaCoreProperties.SUBJECT, XMPDC.SUBJECT);
- extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.TITLE.getName(), TikaCoreProperties.TITLE, XMPDC.TITLE);
- // finds only the first one?!
- extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.TYPE.getName(), TikaCoreProperties.TYPE, XMPDC.TYPE);
-
-
- }
-
- private static void extractPDFVT(XMPMetadata xmp, Metadata metadata) {
- xmp.addXMLNSMapping(XMPSchemaPDFVT.NAMESPACE_URI, XMPSchemaPDFVT.class);
- XMPSchemaPDFVT schema = null;
+ InputStream is;
try {
- schema = (XMPSchemaPDFVT) xmp.getSchemaByClass(XMPSchemaPDFVT.class);
+ is = pdMetadata.exportXMPMetadata();
} catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
-
- if (schema == null) {
+ EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata);
return;
}
- String version = schema.getPDFVTVersion();
- if (! StringUtils.isBlank(version)) {
- metadata.set(PDF.PDFVT_VERSION, version);
- }
- try {
- Calendar modified = schema.getPDFVTModified();
- metadata.set(PDF.PDFVT_MODIFIED, modified);
- } catch (IOException ex) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- "bad date in vt modified");
- }
- }
-
- private static void extractPDFX(XMPMetadata xmp, Metadata metadata) {
- xmp.addXMLNSMapping(XMPSchemaPDFXId.NAMESPACE_URI, XMPSchemaPDFXId.class);
- xmp.addXMLNSMapping(XMPSchemaPDFX.NAMESPACE_URI, XMPSchemaPDFX.class);
- try {
- XMPSchemaPDFXId
- XMPSchemaPDFXId = (XMPSchemaPDFXId) xmp.getSchemaByClass(XMPSchemaPDFXId.class);
- if (XMPSchemaPDFXId != null) {
- String version = XMPSchemaPDFXId.getPDFXVersion();
- if (!StringUtils.isBlank(version)) {
- metadata.set(PDF.PDFXID_VERSION, version);
- }
- }
- } catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
try {
- XMPSchemaPDFX XMPSchemaPDFX = (XMPSchemaPDFX) xmp.getSchemaByClass(XMPSchemaPDFX.class);
- if (XMPSchemaPDFX != null) {
- String version = XMPSchemaPDFX.getPDFXVersion();
- if (!StringUtils.isBlank(version)) {
- metadata.set(PDF.PDFX_VERSION, version);
- }
- String conformance = XMPSchemaPDFX.getPDFXConformance();
- if (!StringUtils.isBlank(conformance)) {
- metadata.set(PDF.PDFX_CONFORMANCE, conformance);
- }
- }
- } catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
-
- }
-
-
- private static void extractPDFUA(XMPMetadata xmp, Metadata metadata) {
- xmp.addXMLNSMapping(XMPSchemaPDFUA.NAMESPACE_URI, XMPSchemaPDFUA.class);
- XMPSchemaPDFUA schema = null;
- try {
- schema = (XMPSchemaPDFUA) xmp.getSchemaByClass(XMPSchemaPDFUA.class);
- } catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
-
- if (schema == null) {
- return;
- }
- try {
- Integer part = schema.getPart();
- if (schema.getPart() != null) {
- metadata.set(PDF.PDFUAID_PART, part.intValue());
- }
- } catch (NumberFormatException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- "expected integer " + "part");
- }
-
- }
-
- private static void extractPDFA(XMPMetadata xmp, Metadata metadata) {
- xmp.addXMLNSMapping(XMPSchemaPDFAId.NAMESPACE, XMPSchemaPDFAId.class);
- XMPSchemaPDFAId schema = null;
- try {
- schema = (XMPSchemaPDFAId) xmp.getSchemaByClass(XMPSchemaPDFAId.class);
- } catch (IOException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- ExceptionUtils.getStackTrace(e));
- }
-
- if (schema == null) {
- return;
- }
- String partString = "UNKNOWN";
- try {
- Integer part = schema.getPart();
- if (part != null) {
- partString = Integer.toString(part);
- metadata.set(PDF.PDFAID_PART, part.intValue());
- }
- } catch (NumberFormatException e) {
- metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
- "expected integer " + "part");
- }
- if (schema.getConformance() != null) {
- metadata.set(PDF.PDFAID_CONFORMANCE, schema.getConformance());
- String version = "A-" + partString + schema.getConformance().toLowerCase(Locale.ROOT);
- metadata.set(PDF.PDFA_VERSION, version);
- }
- }
-
- private static void extractPDF(XMPMetadata xmp, Metadata metadata) {
- if (xmp == null) {
- return;
- }
-
- XMPSchemaPDF pdf = null;
- try {
- pdf = xmp.getPDFSchema();
- } catch (IOException e) {
- return;
- }
- if (pdf == null) {
- return;
+ extract(is, metadata, context);
+ } finally {
+ IOUtils.closeQuietly(is);
}
- setNotNull(pdf.getProducer(), metadata, PDF.PRODUCER, XMPPDF.PRODUCER);
- setNotNull(pdf.getKeywords(), metadata, Office.KEYWORDS, XMPPDF.KEY_WORDS);
- setNotNull(pdf.getPDFVersion(), metadata, PDF.PDF_VERSION, XMPPDF.PDF_VERSION);
}
- private static void extractBasic(XMPMetadata xmp, Metadata metadata) {
- if (xmp == null) {
- return;
- }
-
- XMPSchemaBasic basic = null;
- try {
- basic = xmp.getBasicSchema();
- } catch (IOException e) {
- return;
- }
- if (basic == null) {
- return;
- }
- //add the elements from the basic schema
- setNotNull(basic.getCreatorTool(), metadata, XMP.CREATOR_TOOL);
- setNotNull(basic.getTitle(), metadata, DublinCore.TITLE, XMP.TITLE);
- setNotNull(basic.getAbout(), metadata, XMP.ABOUT);
- setNotNull(basic.getLabel(), metadata, XMP.LABEL);
+ /** Shared XmpExtractor + PDF-specific fixups (octal-BOM decode, derived PDF/A version). */
+ public static void extract(InputStream xmp, Metadata metadata, ParseContext context) {
try {
- setNotNull(XMP.CREATE_DATE, basic.getCreateDate(), metadata);
- } catch (IOException e) {
- //swallow
- }
- try {
- setNotNull(XMP.MODIFY_DATE, basic.getModifyDate(), metadata);
- } catch (IOException e) {
- //swallow
- }
- try {
- setNotNull(XMP.METADATA_DATE, basic.getMetadataDate(), metadata);
- } catch (IOException e) {
- //swallow
- }
-
- List identifiers = basic.getIdentifiers();
- if (identifiers != null) {
- for (String identifier : identifiers) {
- metadata.add(XMP.IDENTIFIER, identifier);
- }
+ // PDF octal-BOM decode is the one XMP value fixup that is container-specific.
+ new XmpExtractor(PDMetadataExtractor::decode).extract(xmp, metadata, context);
+ } catch (SecurityException e) {
+ throw e;
+ } catch (IOException | SAXException | TikaException | RuntimeException e) {
+ EmbeddedDocumentUtil.recordException(e, metadata); // malformed XMP must not fail the PDF
}
- List advisories = basic.getAdvisories();
- if (advisories != null) {
- for (String advisory : advisories) {
- metadata.add(XMP.ADVISORY, advisory);
- }
- }
- setNotNull(basic.getNickname(), metadata, XMP.NICKNAME);
- try {
- setNotNull(XMP.RATING, basic.getRating(), metadata);
- } catch (NumberFormatException e) {
- //swallow TIKA-4401
- }
- //TODO: find an example where basic.getThumbNail is not null
- //and figure out how to add that info
+ derivePDFAVersion(metadata);
}
- private static void setNotNull(String value, Metadata metadata, Property ... properties) {
- if (value == null || value.isBlank()) {
+ /** PDF/A version is derived from the pdfaid part + conformance, e.g. {@code A-1b}. */
+ private static void derivePDFAVersion(Metadata metadata) {
+ String conformance = metadata.get(PDF.PDFAID_CONFORMANCE);
+ if (conformance == null) {
return;
}
- String decoded = decode(value);
- for (Property property : properties) {
- metadata.set(property, decoded);
- }
+ String part = metadata.get(PDF.PDFAID_PART);
+ String partString = (part == null) ? "UNKNOWN" : part;
+ metadata.set(PDF.PDFA_VERSION, "A-" + partString + conformance.toLowerCase(Locale.ROOT));
}
- private static void setNotNull(Property property, Calendar value, Metadata metadata) {
- if (metadata.get(property) == null && value != null) {
- metadata.set(property, value);
- }
- }
-
- private static void setNotNull(Property property, Integer value, Metadata metadata) {
- if (metadata.get(property) == null && value != null) {
- metadata.set(property, value);
- }
- }
-
- static void addNotNull(String value, Metadata metadata, Property ... properties) {
+ static void addNotNull(String value, Metadata metadata, Property... properties) {
if (StringUtils.isBlank(value)) {
return;
}
@@ -365,104 +94,9 @@ static void addNotNull(String value, Metadata metadata, Property ... properties)
}
}
- /**
- * As of this writing, XMPSchema can contain bags or sequence lists
- * for some attributes...despite standards documentation.
- * JempBox expects one or the other for specific attributes.
- * Until more flexibility is added to JempBox, Tika will have to handle both.
- *
- * @param schema
- * @param name
- * @return list of values or null
- */
- static List getXMPBagOrSeqList(XMPSchema schema, String name) {
- List ret = schema.getBagList(name);
- if (ret == null) {
- ret = schema.getSequenceList(name);
- }
- return ret;
- }
-
- /**
- * Try to extract all multilingual items from the XMPSchema
- *
- * This relies on the property having a valid xmp getName()
- *
- * For now, this only extracts the first language if the property does not allow multiple
- * values (see TIKA-1295)
- *
- * @param metadata
- * @param schema schema - must be non-null
- * @param dcName dublin core name for the property to select from the xmp schema
- * @param properties property names to set to this value
- */
- private static void extractMultilingualItems(Metadata metadata, XMPSchema schema, String dcName, Property ... properties) {
-
- for (Property property : properties) {
- for (String lang : schema.getLanguagePropertyLanguages(dcName)) {
- String value = schema.getLanguageProperty(dcName, lang);
- if (value != null && ! value.isBlank()) {
- addMetadata(metadata, property, value);
- addMetadata(metadata, property.getName() + ":" + lang, value);
- }
- }
- }
- }
-
-
- /**
- * This tries to read a list from a particular property in
- * XMPSchemaDublinCore.
- *
- * Until PDFBOX-1803/TIKA-1233 are fixed, do not call this
- * on dates!
- *
- * This relies on the property having a DublinCore compliant getName()
- *
- * @param metadata
- * @param dc schema - must be non-null
- * @param dcName -- name of the dc property to read from the dc schema
- * @param properties -- property to set for this value in the metadata object
- */
- private static void extractDublinCoreListItems(Metadata metadata,
- XMPSchemaDublinCore dc, String dcName, Property ... properties) {
-
- List items = getXMPBagOrSeqList(dc, dcName);
- if (items == null) {
- return;
- }
- for (Property property : properties) {
- for (String item : items) {
- addMetadata(metadata, property, item);
- }
- }
- }
-
- /**
- * This tries to read a string from a particular property in XMPSchemaDublinCore.
- *
- * This relies on the property having a DublinCore compliant getName()
- *
- * @param metadata
- * @param dc schema - must be non-null
- * @param dcName -- name of the dc property to read from the dc schema
- * @param properties -- property to set for this value in the metadata object
- */
- private static void extractDublinCoreSimpleItem(Metadata metadata,
- XMPSchemaDublinCore dc, String dcName, Property ... properties) {
-
- String textProperty = dc.getTextProperty(dcName);
- for (Property property : properties) {
- addMetadata(metadata, property, textProperty);
- }
- }
-
/**
* Add non-null, non-empty and unique values to the Metadata object. If the property
* does not allow multiple values, silently fail to add values after the first.
- * @param metadata
- * @param property
- * @param value
*/
static void addMetadata(Metadata metadata, Property property, String value) {
if (value == null || value.isBlank()) {
@@ -506,31 +140,6 @@ static String decode(String value) {
return value;
}
- //can return null!
- private static Document loadDOM(PDMetadata pdMetadata, Metadata metadata,
- ParseContext context) {
- if (pdMetadata == null) {
- return null;
- }
-
- InputStream is = null;
- try {
- try {
- is = pdMetadata.exportXMPMetadata();
- } catch (IOException e) {
- EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata);
- return null;
- }
- return XMLReaderUtils.buildDOM(is, context);
- } catch (IOException | SAXException | TikaException e) {
- EmbeddedDocumentUtil.recordException(e, metadata);
- } finally {
- IOUtils.closeQuietly(is);
- }
- return null;
-
- }
-
static void addMetadata(Metadata metadata, Property property, Calendar value) {
if (value != null) {
metadata.set(property, value);
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java
deleted file mode 100644
index 9b823fe55a4..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.pdf.xmpschemas;
-
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.w3c.dom.Element;
-
-import org.apache.tika.mime.MediaType;
-
-public class XMPSchemaIllustrator extends XMPSchema {
- public static final String NAMESPACE_URI = "http://ns.adobe.com/illustrator/1.0/";
- public static final String NAMESPACE = "illustrator";
-
- public static final String ILLUSTRATOR = MediaType.application("illustrator").toString();
-
- public XMPSchemaIllustrator(XMPMetadata parent) {
- super(parent, NAMESPACE, NAMESPACE_URI);
- }
-
- public XMPSchemaIllustrator(Element element, String prefix) {
- super(element, prefix);
- }
-
- public String getType() {
- return this.getTextProperty(this.prefix + ":Type");
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java
deleted file mode 100644
index f788daf46a4..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.pdf.xmpschemas;
-
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.w3c.dom.Element;
-
-public class XMPSchemaPDFUA extends XMPSchema {
- public static final String NAMESPACE_URI = "http://www.aiim.org/pdfua/ns/id/";
- public static final String NAMESPACE = "pdfuaid";
-
- public XMPSchemaPDFUA(XMPMetadata parent) {
- super(parent, NAMESPACE, NAMESPACE_URI);
- }
-
- public XMPSchemaPDFUA(Element element, String prefix) {
- super(element, prefix);
- }
-
- public Integer getPart() {
- return this.getIntegerProperty(this.prefix + ":part");
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java
deleted file mode 100644
index 22b54a9dcaa..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.pdf.xmpschemas;
-
-import java.io.IOException;
-import java.util.Calendar;
-
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.w3c.dom.Element;
-
-public class XMPSchemaPDFVT extends XMPSchema {
- public static final String NAMESPACE_URI = "http://www.npes.org/pdfvt/ns/id/";
- public static final String NAMESPACE = "pdfvtid";
-
- private Calendar vTModified;
- public XMPSchemaPDFVT(XMPMetadata parent) {
- super(parent, NAMESPACE, NAMESPACE_URI);
- }
-
- public XMPSchemaPDFVT(Element element, String prefix) {
- super(element, prefix);
- }
-
- public String getPDFVTVersion() {
- return this.getTextProperty(this.prefix + ":GTS_PDFVTVersion");
- }
-
- public Calendar getPDFVTModified() throws IOException {
- return this.getDateProperty(this.prefix + ":GTS_PDFVTModDate");
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java
deleted file mode 100644
index 612784159fb..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.pdf.xmpschemas;
-
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.w3c.dom.Element;
-
-/**
- * This is somewhat of a hack to handle the older pdfx:
- * See also the more modern {@link XMPSchemaPDFXId}
- */
-public class XMPSchemaPDFX extends XMPSchema {
- public static final String NAMESPACE_URI = "http://ns.adobe.com/pdfx/1.3/";
- public static final String NAMESPACE = "pdfx";
-
- public XMPSchemaPDFX(XMPMetadata parent) {
- super(parent, NAMESPACE, NAMESPACE_URI);
- }
-
- public XMPSchemaPDFX(Element element, String prefix) {
- super(element, prefix);
- }
-
- public String getPDFXVersion() {
- return this.getTextProperty(this.prefix + ":GTS_PDFXVersion");
- }
-
- public String getPDFXConformance() {
- return this.getTextProperty(this.prefix + ":GTS_PDFXConformance");
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java
deleted file mode 100644
index 02e14ddf692..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.pdf.xmpschemas;
-
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchema;
-import org.w3c.dom.Element;
-
-public class XMPSchemaPDFXId extends XMPSchema {
- public static final String NAMESPACE_URI = "http://www.npes.org/pdfx/ns/id/";
- public static final String NAMESPACE = "pdfxid";
-
- public XMPSchemaPDFXId(XMPMetadata parent) {
- super(parent, NAMESPACE, NAMESPACE_URI);
- }
-
- public XMPSchemaPDFXId(Element element, String prefix) {
- super(element, prefix);
- }
-
- public String getPDFXVersion() {
- return this.getTextProperty(this.prefix + ":GTS_PDFXVersion");
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java
index 3008251dec1..ecd328446fe 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java
@@ -17,12 +17,13 @@
package org.apache.tika.parser.pdf;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
-import org.apache.jempbox.xmp.XMPMetadata;
import org.junit.jupiter.api.Test;
-import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import org.apache.tika.TikaTest;
@@ -33,7 +34,6 @@
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMP;
import org.apache.tika.parser.ParseContext;
-import org.apache.tika.utils.XMLReaderUtils;
/**
* This tests our custom schemas and PDF/A
@@ -60,9 +60,11 @@ public void testPDFX() throws Exception {
public void testPDFUA() throws Exception {
Metadata metadata = extract("testPDFUA.xmp");
assertEquals(1, metadata.getInt(PDF.PDFUAID_PART));
- String[] subjects = metadata.getValues(TikaCoreProperties.SUBJECT);
- assertEquals("keywords", subjects[0]);
- assertEquals("subject", subjects[1]);
+ // keywords and subject both land in SUBJECT; presence is the contract, order is not.
+ List subjects = Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
+ assertEquals(2, subjects.size());
+ assertTrue(subjects.contains("keywords"), "keywords in SUBJECT");
+ assertTrue(subjects.contains("subject"), "subject in SUBJECT");
assertEquals("1234567890", metadata.get(XMP.IDENTIFIER));
assertEquals("Advisory", metadata.get(XMP.ADVISORY));
}
@@ -109,11 +111,8 @@ public void testDublinCore() throws Exception {
private Metadata extract(String xmpFileName) throws IOException, TikaException, SAXException {
try (TikaInputStream tis = getResourceAsStream("/test-documents/xmp/" + xmpFileName)) {
- Document doc = XMLReaderUtils.buildDOM(tis);
- XMPMetadata xmp = new XMPMetadata(doc);
- ParseContext context = new ParseContext();
Metadata metadata = new Metadata();
- PDMetadataExtractor.extract(xmp, metadata, context);
+ PDMetadataExtractor.extract(tis, metadata, new ParseContext());
return metadata;
}
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
index 987d2c70836..26eab218dc7 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
@@ -1570,10 +1570,14 @@ public void testMetadataKeyPrecision() throws Exception {
assertEquals("xmp-xmpmm-documentid", m.get(XMPMM.DOCUMENTID));
assertEquals("13", m.get(PagedText.N_PAGES));
+ // keywords/subject from XMP and doc-info merge into SUBJECT; presence is the contract, order is not.
String[] expectedSubjectVals = new String[]{
"xmp-pdf-keywords", "xmp-dc-subject", "pdf-keywords", "pdf-subject"
};
- assertArrayEquals(expectedSubjectVals, m.getValues(TikaCoreProperties.SUBJECT));
+ String[] actualSubjectVals = m.getValues(TikaCoreProperties.SUBJECT);
+ assertEquals(expectedSubjectVals.length, actualSubjectVals.length);
+ assertEquals(new HashSet<>(Arrays.asList(expectedSubjectVals)),
+ new HashSet<>(Arrays.asList(actualSubjectVals)));
}
@Test
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml
index 7ccc27297fe..ef174b07da7 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml
@@ -31,14 +31,12 @@
+
org.apache.pdfbox
- jempbox
- ${jempbox.version}
-
-
- org.apache.pdfbox
- xmpbox
+ pdfbox
${pdfbox.version}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java
deleted file mode 100644
index e231a3190f5..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java
+++ /dev/null
@@ -1,246 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.xmp;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Calendar;
-import java.util.List;
-import java.util.StringJoiner;
-
-import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
-import org.apache.jempbox.xmp.ResourceEvent;
-import org.apache.jempbox.xmp.ResourceRef;
-import org.apache.jempbox.xmp.XMPMetadata;
-import org.apache.jempbox.xmp.XMPSchemaDublinCore;
-import org.apache.jempbox.xmp.XMPSchemaMediaManagement;
-import org.w3c.dom.Document;
-import org.xml.sax.SAXException;
-
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.Property;
-import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.metadata.XMPMM;
-import org.apache.tika.parser.ParseContext;
-import org.apache.tika.utils.DateUtils;
-import org.apache.tika.utils.XMLReaderUtils;
-
-public class JempboxExtractor {
-
- //TODO: change signature to require parsecontext from parse
- private static final ParseContext EMPTY_PARSE_CONTEXT = new ParseContext();
- // The XMP spec says it must be unicode, but for most file formats it specifies
- // "must be encoded in UTF-8"
- private static final String DEFAULT_XMP_CHARSET = UTF_8.name();
- private static volatile int MAX_EVENT_HISTORY_IN_XMPMM = 1024;
- private XMPPacketScanner scanner = new XMPPacketScanner();
- private Metadata metadata;
-
- public JempboxExtractor(Metadata metadata) {
- this.metadata = metadata;
- }
-
- /**
- * Tries to extract Dublin Core schema from XMP. If XMPMetadata is null
- * or if the DC schema is null, this will return without throwing an exception.
- *
- * @param xmpMetadata XMPMetadata to process
- * @param metadata Tika's metadata to write to
- */
- public static void extractDublinCore(XMPMetadata xmpMetadata, Metadata metadata) {
- if (xmpMetadata == null) {
- return;
- }
- XMPSchemaDublinCore dc = null;
- try {
- dc = xmpMetadata.getDublinCoreSchema();
- } catch (IOException e) {
- //swallow
- }
- if (dc == null) {
- return;
- }
- if (dc.getTitle() != null) {
- metadata.set(TikaCoreProperties.TITLE, dc.getTitle());
- }
- if (dc.getDescription() != null) {
- metadata.set(TikaCoreProperties.DESCRIPTION, dc.getDescription());
- }
- if (dc.getCreators() != null && dc.getCreators().size() > 0) {
- metadata.set(TikaCoreProperties.CREATOR, joinCreators(dc.getCreators()));
- }
- if (dc.getSubjects() != null && dc.getSubjects().size() > 0) {
- for (String keyword : dc.getSubjects()) {
- metadata.add(TikaCoreProperties.SUBJECT, keyword);
- }
- // TODO should we set SUBJECT too?
- // All tested photo managers set the same in Iptc.Application2.Keywords
- // and Xmp.dc.subject
- }
- }
-
- protected static String joinCreators(List creators) {
- if (creators == null || creators.size() == 0) {
- return "";
- }
- if (creators.size() == 1) {
- return creators.get(0);
- }
- StringJoiner stringJoiner = new StringJoiner(", ");
- for (String s : creators) {
- stringJoiner.add(s);
- }
- return stringJoiner.toString();
- }
-
- /**
- * Extracts Media Management metadata from XMP.
- *
- * Silently swallows exceptions.
- *
- * @param xmp
- * @param metadata
- */
- public static void extractXMPMM(XMPMetadata xmp, Metadata metadata) {
- if (xmp == null) {
- return;
- }
- XMPSchemaMediaManagement mmSchema = null;
- try {
- mmSchema = xmp.getMediaManagementSchema();
- } catch (IOException e) {
- //swallow
- return;
- }
- if (mmSchema != null) {
- addMetadata(metadata, XMPMM.DOCUMENTID, mmSchema.getDocumentID());
- // not currently supported by JempBox...
- // but might be in 1.8.18 if ever released, see PDFBOX-6116
- // until then use workaround (won't work if non standard prefix is used)
- metadata.set(XMPMM.INSTANCEID, mmSchema.getTextProperty("xmpMM:InstanceID" ));
-
- ResourceRef derivedFrom = mmSchema.getDerivedFrom();
- if (derivedFrom != null) {
- try {
- addMetadata(metadata, XMPMM.DERIVED_FROM_DOCUMENTID,
- derivedFrom.getDocumentID());
- } catch (NullPointerException e) {
- //swallow
- // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released
- }
-
- try {
- addMetadata(metadata, XMPMM.DERIVED_FROM_INSTANCEID,
- derivedFrom.getInstanceID());
- } catch (NullPointerException e) {
- //swallow
- // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released
- }
-
- //TODO: not yet supported by XMPBox...extract OriginalDocumentID
- //in DerivedFrom section
- }
- if (mmSchema.getHistory() != null) {
- int eventsAdded = 0;
- for (ResourceEvent stevt : mmSchema.getHistory()) {
- if (eventsAdded >= MAX_EVENT_HISTORY_IN_XMPMM) {
- break;
- }
- String instanceId = null;
- String action = null;
- Calendar when = null;
- String softwareAgent = null;
- try {
- instanceId = stevt.getInstanceID();
- action = stevt.getAction();
- when = stevt.getWhen();
- softwareAgent = stevt.getSoftwareAgent();
-
- //instanceid can throw npe; getWhen can throw IOException
- } catch (NullPointerException | IOException e) {
- //swallow
- // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released
- }
- if (instanceId != null && !instanceId.isBlank()) {
- //for absent data elements, pass in empty strings so
- //that parallel arrays will have matching offsets
- //for absent data
-
- action = (action == null) ? "" : action;
- String dateString = (when == null) ? "" : DateUtils.formatDate(when);
- softwareAgent = (softwareAgent == null) ? "" : softwareAgent;
-
- metadata.add(XMPMM.HISTORY_EVENT_INSTANCEID, instanceId);
- metadata.add(XMPMM.HISTORY_ACTION, action);
- metadata.add(XMPMM.HISTORY_WHEN, dateString);
- metadata.add(XMPMM.HISTORY_SOFTWARE_AGENT, softwareAgent);
- eventsAdded++;
- }
- }
- }
- }
- }
-
- private static void addMetadata(Metadata m, Property p, String value) {
- if (value != null) {
- if (p.isMultiValuePermitted() || m.get(p) == null) {
- m.add(p, value);
- }
- }
- }
-
- /**
- * @return maximum number of events to extract from the XMPMM history.
- */
- public static int getMaxXMPMMHistory() {
- return MAX_EVENT_HISTORY_IN_XMPMM;
- }
-
- /**
- * Maximum number of events to extract from the
- * event history in the XMP Media Management (XMPMM) section.
- * The extractor will silently stop adding events after it
- * has reached this threshold.
- *
- * The default is 1024.
- */
- public static void setMaxXMPMMHistory(int maxEvents) {
- MAX_EVENT_HISTORY_IN_XMPMM = maxEvents;
- }
-
- public void parse(InputStream file) throws IOException, TikaException {
- UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get();
- if (!scanner.parse(file, xmpraw)) {
- return;
- }
-
- XMPMetadata xmp = null;
- try (InputStream decoded = xmpraw.toInputStream()) {
- Document dom = XMLReaderUtils.buildDOM(decoded, EMPTY_PARSE_CONTEXT);
- if (dom != null) {
- xmp = new XMPMetadata(dom);
- }
- } catch (IOException | SAXException e) {
- //
- }
- extractDublinCore(xmp, metadata);
- extractXMPMM(xmp, metadata);
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java
deleted file mode 100644
index d9b9b38c34a..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.xmp;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Calendar;
-import java.util.List;
-
-import org.apache.commons.io.input.CloseShieldInputStream;
-import org.apache.xmpbox.XMPMetadata;
-import org.apache.xmpbox.schema.DublinCoreSchema;
-import org.apache.xmpbox.schema.XMPBasicSchema;
-import org.apache.xmpbox.schema.XMPMediaManagementSchema;
-import org.apache.xmpbox.type.AbstractField;
-import org.apache.xmpbox.type.ArrayProperty;
-import org.apache.xmpbox.type.BadFieldValueException;
-import org.apache.xmpbox.type.ResourceEventType;
-import org.apache.xmpbox.type.ResourceRefType;
-import org.apache.xmpbox.xml.DomXmpParser;
-
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.metadata.DublinCore;
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.Property;
-import org.apache.tika.metadata.XMP;
-import org.apache.tika.metadata.XMPMM;
-import org.apache.tika.utils.DateUtils;
-
-/**
- * XMP Metadata Extractor based on Apache XmpBox.
- */
-public class XMPMetadataExtractor {
-
- private static volatile int MAX_EVENT_HISTORY_IN_XMPMM = 1024;
-
- /**
- * Parse the XMP Packets.
- *
- * @param stream the stream to parser.
- * @param metadata the metadata collection to update
- * @throws IOException on any IO error.
- * @throws TikaException on any Tika error.
- */
- public static void parse(InputStream stream, Metadata metadata) throws IOException, TikaException {
- XMPMetadata xmp;
- try {
- DomXmpParser xmpParser = new DomXmpParser();
- xmpParser.setStrictParsing(false);
- xmp = xmpParser.parse(CloseShieldInputStream.wrap(stream));
- } catch (Throwable ex) {
- //swallow
- return;
- }
- extractDublinCoreSchema(xmp, metadata);
- extractXMPBasicSchema(xmp, metadata);
- extractXMPMM(xmp, metadata);
- }
-
- /**
- * Extracts Dublin Core.
- *
- * Silently swallows exceptions.
- * @param xmp the XMP Metadata object.
- * @param metadata the metadata map
- * @throws IOException
- */
- public static void extractDublinCoreSchema(XMPMetadata xmp, Metadata metadata) throws IOException {
- if (xmp == null) {
- return;
- }
- DublinCoreSchema schemaDublinCore = xmp.getDublinCoreSchema();
- if (schemaDublinCore != null) {
- try {
- addMetadata(metadata, DublinCore.TITLE, schemaDublinCore.getTitle());
- addMetadata(metadata, DublinCore.FORMAT, schemaDublinCore.getFormat());
- addMetadata(metadata, DublinCore.DESCRIPTION, schemaDublinCore.getDescription());
- addMetadata(metadata, DublinCore.CREATOR, schemaDublinCore.getCreators());
- addMetadata(metadata, DublinCore.SUBJECT, schemaDublinCore.getSubjects());
- }
- catch (BadFieldValueException ex) {
- throw new IOException(ex);
- }
- }
- }
-
- /**
- * Extracts basic schema metadata from XMP.
- *
- * Silently swallows exceptions.
- * @param xmp the XMP Metadata object.
- * @param metadata the metadata map
- * @throws IOException
- */
- public static void extractXMPBasicSchema(XMPMetadata xmp, Metadata metadata) throws IOException {
- if (xmp == null) {
- return;
- }
- XMPBasicSchema schemaBasic = xmp.getXMPBasicSchema();
- if (schemaBasic != null) {
- addMetadata(metadata, XMP.CREATOR_TOOL, schemaBasic.getCreatorTool());
- addMetadata(metadata, XMP.CREATE_DATE, schemaBasic.getCreateDate());
- addMetadata(metadata, XMP.MODIFY_DATE, schemaBasic.getModifyDate());
- addMetadata(metadata, XMP.METADATA_DATE, schemaBasic.getModifyDate());
- addMetadata(metadata, XMP.RATING, schemaBasic.getRating());
- }
- }
-
- /**
- * @return maximum number of events to extract from the XMPMM history.
- */
- public static int getMaxXMPMMHistory() {
- return MAX_EVENT_HISTORY_IN_XMPMM;
- }
-
- /**
- * Maximum number of events to extract from the
- * event history in the XMP Media Management (XMPMM) section.
- * The extractor will silently stop adding events after it
- * has reached this threshold.
- *
- * The default is 1024.
- * @param maxEvents
- */
- public static void setMaxXMPMMHistory(int maxEvents) {
- MAX_EVENT_HISTORY_IN_XMPMM = maxEvents;
- }
-
- /**
- * Extracts Media Management metadata from XMP.
- *
- * Silently swallows exceptions.
- *
- * @param xmp
- * @param metadata
- */
- public static void extractXMPMM(XMPMetadata xmp, Metadata metadata) {
- if (xmp == null) {
- return;
- }
- XMPMediaManagementSchema mmSchema = xmp.getXMPMediaManagementSchema();
- if (mmSchema != null) {
- addMetadata(metadata, XMPMM.DOCUMENTID, mmSchema.getDocumentID());
- metadata.set(XMPMM.INSTANCEID, mmSchema.getInstanceID());
- metadata.set(XMPMM.ORIGINAL_DOCUMENTID, mmSchema.getOriginalDocumentID());
-
- ResourceRefType derivedFrom = mmSchema.getDerivedFromProperty();
-
- if (derivedFrom != null) {
- addMetadata(metadata, XMPMM.DERIVED_FROM_DOCUMENTID, derivedFrom.getDocumentID());
- addMetadata(metadata, XMPMM.DERIVED_FROM_INSTANCEID, derivedFrom.getInstanceID());
- }
- ArrayProperty historyProperty = mmSchema.getHistoryProperty();
- if (historyProperty != null) {
- int eventsAdded = 0;
- for (AbstractField af : historyProperty.getAllProperties()) {
- if (eventsAdded >= MAX_EVENT_HISTORY_IN_XMPMM) {
- break;
- }
- if (!(af instanceof ResourceEventType))
- {
- continue;
- }
- ResourceEventType stevt = (ResourceEventType) af;
- String instanceId = stevt.getInstanceID();
- String action = stevt.getAction();
- Calendar when = stevt.getWhen();
- String softwareAgent = stevt.getSoftwareAgent();
- if (instanceId != null && !instanceId.isBlank())
- {
- // for absent data elements, pass in empty strings so
- // that parallel arrays will have matching offsets for absent data
- action = action == null ? "" : action;
- String dateString = when == null ? "" : DateUtils.formatDate(when);
- softwareAgent = softwareAgent == null ? "" : softwareAgent;
-
- metadata.add(XMPMM.HISTORY_EVENT_INSTANCEID, instanceId);
- metadata.add(XMPMM.HISTORY_ACTION, action);
- metadata.add(XMPMM.HISTORY_WHEN, dateString);
- metadata.add(XMPMM.HISTORY_SOFTWARE_AGENT, softwareAgent);
- eventsAdded++;
- }
- }
- }
- }
- }
-
- /**
- * Add list to the metadata map.
- *
- * @param metadata the metadata map to update.
- * @param property the property to add.
- * @param values the values to add.
- */
- private static void addMetadata(Metadata metadata, Property property, List values) {
- if (values != null) {
- for (String value : values) {
- addMetadata(metadata, property, value);
- }
- }
- }
-
- /**
- * Add value to the metadata map.
- *
- * @param metadata the metadata map to update.
- * @param property the property to add.
- * @param value the value to add.
- */
- private static void addMetadata(Metadata metadata, Property property, String value) {
- if (value != null) {
- if (property.isMultiValuePermitted()) {
- metadata.add(property, value);
- } else {
- metadata.set(property, value);
- }
- }
- }
-
- /**
- * Add value to the metadata map.
- *
- * @param metadata the metadata map to update.
- * @param property the property to add.
- * @param value the value to add.
- */
- private static void addMetadata(Metadata metadata, Property property, Integer value) {
- if (value != null) {
- if (property.isMultiValuePermitted()) {
- metadata.add(property, value);
- } else {
- metadata.set(property, value);
- }
- }
- }
-
- /**
- * Add value to the metadata map.
- *
- * @param metadata the metadata map to update.
- * @param property the property to add.
- * @param value the value to add.
- */
- private static void addMetadata(Metadata metadata, Property property, Calendar value) {
- if (value != null) {
- metadata.set(property, value);
- }
- }
-
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java
index 7964f059be7..867338a2f0e 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java
@@ -50,10 +50,14 @@ private static boolean skipAfter(InputStream in, byte[] match) throws IOExceptio
return skipAfter(in, match, null);
}
+ // Cap the captured packet so a crafted stream can't read an unbounded xpacket into memory.
+ private static final long MAX_PACKET = 64L * 1024 * 1024;
+
private static boolean skipAfter(InputStream in, byte[] match, OutputStream out)
throws IOException {
int found = 0;
int len = match.length;
+ long written = 0;
int b;
while ((b = in.read()) >= 0) {
if (b == match[found]) {
@@ -65,8 +69,12 @@ private static boolean skipAfter(InputStream in, byte[] match, OutputStream out)
if (out != null) {
if (found > 0) {
out.write(match, 0, found);
+ written += found;
}
out.write(b);
+ if (++written > MAX_PACKET) {
+ throw new IOException("XMP packet exceeds " + MAX_PACKET + " bytes");
+ }
}
found = 0;
}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java
new file mode 100644
index 00000000000..92f5f6ace2f
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import java.util.Calendar;
+import java.util.Date;
+
+import org.apache.pdfbox.util.DateConverter;
+
+import org.apache.tika.utils.DateUtils;
+
+/** XMP date string -> canonical ISO-8601 UTC (seconds), or null if unrecognizable. */
+public final class XmpDates {
+
+ private XmpDates() {
+ }
+
+ public static String normalize(String raw) {
+ if (raw == null) {
+ return null;
+ }
+ String s = raw.trim();
+ if (s.isEmpty()) {
+ return null;
+ }
+ // Known gap: a partial date (YYYY, YYYY-MM) inflates to a full timestamp -- preserving it
+ // breaks Metadata.getDate() (can't re-parse year/month-only). Deferred to the
+ // value-representation ticket. PDFBox handles PDF D: dates, producer formats, robust TZ.
+ try {
+ Calendar c = DateConverter.toCalendar(s);
+ if (c != null) {
+ return DateUtils.formatDate(c);
+ }
+ } catch (SecurityException e) {
+ throw e;
+ } catch (Exception e) {
+ // fall through
+ }
+ // DateUtils fallback catches EXIF yyyy:MM:dd; not thread-safe, so new instance.
+ Date d = new DateUtils().tryToParse(s);
+ if (d != null) {
+ return DateUtils.formatDate(d);
+ }
+ return null;
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java
new file mode 100644
index 00000000000..9ae8b4b325f
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java
@@ -0,0 +1,452 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.UnaryOperator;
+import java.util.regex.Pattern;
+
+import org.xml.sax.SAXException;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.DublinCore;
+import org.apache.tika.metadata.Google;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.PDF;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.Photoshop;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.XMP;
+import org.apache.tika.metadata.XMPDC;
+import org.apache.tika.metadata.XMPMM;
+import org.apache.tika.metadata.XMPPDF;
+import org.apache.tika.metadata.XMPRights;
+import org.apache.tika.metadata.XMPTIFF;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Container-agnostic XMP extractor: SAX-flatten a packet, map to canonical Tika
+ * properties, normalize dates. Same mapping regardless of source container.
+ */
+public class XmpExtractor {
+
+ private static final String RESOURCE_EVENT_NS = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#";
+ private static final String RESOURCE_REF_NS = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#";
+
+ // (namespaceURI, localName) -> Tika properties (canonical + xmp-namespaced, Option A double-keying)
+ private static final Map TABLE = new HashMap<>();
+ // ResourceEvent field -> xmpMM:History parallel bag
+ private static final Map HISTORY = new HashMap<>();
+ // ResourceRef field (within DerivedFrom) -> property
+ private static final Map DERIVED_FROM = new HashMap<>();
+ // Google camera localName -> property
+ private static final Map GOOGLE_CAMERA = new HashMap<>();
+ // "uri localName" -> canonical date filled set-if-absent (docinfo/EXIF still win)
+ private static final Map FILL_IF_ABSENT = new HashMap<>();
+ // "uri localName" keys that are neither mapped nor passed through as raw (known junk/bloat)
+ private static final Set DROP = new HashSet<>();
+ // Unmapped XMP goes under this prefix so raw, untrusted keys can't shadow a known Tika or
+ // X-TIKA: field. Best-effort discovery surface: keys use the document's prefix (not the URI)
+ // and are non-contractual -- promote a field into TABLE when it needs a stable key.
+ static final String RAW_PREFIX = "xmp-raw:";
+ // strip a trailing array index so a raw bag/seq is one multi-valued key, not foo:Bag[1], foo:Bag[2]
+ private static final Pattern TRAILING_INDEX = Pattern.compile("\\[\\d+\\]$");
+
+ // History parallel bags, fixed order; emitHistory pads absent fields so bag[i] stays aligned.
+ private static final Property[] HISTORY_PROPS = {
+ XMPMM.HISTORY_ACTION, XMPMM.HISTORY_WHEN, XMPMM.HISTORY_EVENT_INSTANCEID,
+ XMPMM.HISTORY_SOFTWARE_AGENT, XMPMM.HISTORY_CHANGED, XMPMM.HISTORY_PARAMETERS,
+ };
+ // hard cap on history events exposed, so a hostile packet can't inflate metadata without bound
+ private static final int MAX_HISTORY_EVENTS = 1024;
+
+ private static void put(String uri, String localName, Property... props) {
+ TABLE.put(uri + " " + localName, props);
+ }
+
+ static {
+ String dc = DublinCore.NAMESPACE_URI_DC;
+ put(dc, "title", TikaCoreProperties.TITLE, XMPDC.TITLE);
+ put(dc, "creator", TikaCoreProperties.CREATOR, XMPDC.CREATOR);
+ put(dc, "subject", TikaCoreProperties.SUBJECT, XMPDC.SUBJECT);
+ put(dc, "description", TikaCoreProperties.DESCRIPTION, XMPDC.DESCRIPTION);
+ put(dc, "publisher", DublinCore.PUBLISHER, XMPDC.PUBLISHER);
+ put(dc, "contributor", DublinCore.CONTRIBUTOR, XMPDC.CONTRIBUTOR);
+ put(dc, "type", DublinCore.TYPE, XMPDC.TYPE);
+ // dc:format/dc:date -> xmp key only: canonical FORMAT is the real MIME type, dc:date is
+ // ambiguous; the unambiguous xmp:CreateDate/ModifyDate fill canonical created/modified below.
+ put(dc, "format", XMPDC.FORMAT);
+ put(dc, "identifier", DublinCore.IDENTIFIER, XMPDC.IDENTIFIER);
+ put(dc, "language", DublinCore.LANGUAGE, XMPDC.LANGUAGE);
+ put(dc, "relation", DublinCore.RELATION, XMPDC.RELATION);
+ put(dc, "source", DublinCore.SOURCE, XMPDC.SOURCE);
+ put(dc, "coverage", DublinCore.COVERAGE, XMPDC.COVERAGE);
+ put(dc, "rights", DublinCore.RIGHTS, XMPDC.RIGHTS);
+ put(dc, "date", XMPDC.DATE);
+
+ String xmp = XMP.NAMESPACE_URI;
+ put(xmp, "Title", TikaCoreProperties.TITLE, XMP.TITLE);
+ put(xmp, "CreateDate", XMP.CREATE_DATE);
+ put(xmp, "ModifyDate", XMP.MODIFY_DATE);
+ put(xmp, "MetadataDate", XMP.METADATA_DATE);
+ put(xmp, "CreatorTool", XMP.CREATOR_TOOL);
+ put(xmp, "Rating", XMP.RATING);
+ put(xmp, "Label", XMP.LABEL);
+ put(xmp, "Nickname", XMP.NICKNAME);
+ put(xmp, "Identifier", XMP.IDENTIFIER);
+ put(xmp, "Advisory", XMP.ADVISORY);
+ // rdf:about (the packet's document URI) -> xmp:About; flattener emits it only when non-empty
+ put(XmpSaxFlattener.RDF, "about", XMP.ABOUT);
+
+ String mm = XMPMM.NAMESPACE_URI;
+ put(mm, "DocumentID", XMPMM.DOCUMENTID);
+ put(mm, "InstanceID", XMPMM.INSTANCEID);
+ put(mm, "OriginalDocumentID", XMPMM.ORIGINAL_DOCUMENTID);
+ put(mm, "RenditionClass", XMPMM.RENDITION_CLASS);
+ put(mm, "RenditionParams", XMPMM.RENDITION_PARAMS);
+
+ String pdf = "http://ns.adobe.com/pdf/1.3/";
+ put(pdf, "Producer", PDF.PRODUCER, XMPPDF.PRODUCER);
+ put(pdf, "Keywords", Office.KEYWORDS, XMPPDF.KEY_WORDS);
+ put(pdf, "PDFVersion", PDF.PDF_VERSION, XMPPDF.PDF_VERSION);
+
+ String rights = XMPRights.NAMESPACE_URI_XMP_RIGHTS;
+ put(rights, "Marked", XMPRights.MARKED);
+ put(rights, "WebStatement", XMPRights.WEB_STATEMENT);
+ put(rights, "UsageTerms", XMPRights.USAGE_TERMS);
+ put(rights, "Owner", XMPRights.OWNER);
+ put(rights, "Certificate", XMPRights.CERTIFICATE);
+
+ // Promoted from raw passthrough; these namespaces are XMP-exclusive, so one canonical key suffices.
+ String ps = Photoshop.NAMESPACE_URI_PHOTOSHOP;
+ put(ps, "ColorMode", Photoshop.COLOR_MODE);
+ put(ps, "ICCProfile", Photoshop.ICC_PROFILE);
+ put(ps, "DateCreated", Photoshop.DATE_CREATED);
+ put("http://ns.adobe.com/xap/1.0/t/pg/", "NPages", PagedText.N_PAGES);
+ put(pdf, "Trapped", PDF.TRAPPED);
+
+ // Camera/EXIF/TIFF -> shared TIFF interface. tiff:/exif: double-keyed: the canonical key may
+ // also come from binary EXIF (extracted later, so it wins); the XMPTIFF key keeps XMP
+ // provenance. aux: is XMP-only -> single key. Rationals/ResolutionUnit/Flash stay raw pending
+ // value normalization.
+ String aux = "http://ns.adobe.com/exif/1.0/aux/";
+ put(aux, "SerialNumber", TIFF.SERIAL_NUMBER);
+ put(aux, "Lens", TIFF.LENS);
+ put(aux, "LensInfo", TIFF.LENS_INFO);
+ put(aux, "LensID", TIFF.LENS_ID);
+ String tiff = "http://ns.adobe.com/tiff/1.0/";
+ put(tiff, "Make", TIFF.EQUIPMENT_MAKE, XMPTIFF.EQUIPMENT_MAKE);
+ put(tiff, "Model", TIFF.EQUIPMENT_MODEL, XMPTIFF.EQUIPMENT_MODEL);
+ put(tiff, "Software", TIFF.SOFTWARE, XMPTIFF.SOFTWARE);
+ put(tiff, "ImageWidth", TIFF.IMAGE_WIDTH, XMPTIFF.IMAGE_WIDTH);
+ put(tiff, "ImageLength", TIFF.IMAGE_LENGTH, XMPTIFF.IMAGE_LENGTH);
+ put(tiff, "BitsPerSample", TIFF.BITS_PER_SAMPLE, XMPTIFF.BITS_PER_SAMPLE);
+ put(tiff, "SamplesPerPixel", TIFF.SAMPLES_PER_PIXEL, XMPTIFF.SAMPLES_PER_PIXEL);
+ put(tiff, "Orientation", TIFF.ORIENTATION, XMPTIFF.ORIENTATION);
+ String exif = "http://ns.adobe.com/exif/1.0/";
+ put(exif, "DateTimeOriginal", TIFF.ORIGINAL_DATE, XMPTIFF.ORIGINAL_DATE);
+ put(exif, "ISOSpeedRatings", TIFF.ISO_SPEED_RATINGS, XMPTIFF.ISO_SPEED_RATINGS);
+ // exif:Pixel*Dimension -> same IMAGE_WIDTH/LENGTH as binary EXIF (integers, no format question).
+ put(exif, "PixelXDimension", TIFF.IMAGE_WIDTH, XMPTIFF.PIXEL_X_DIMENSION);
+ put(exif, "PixelYDimension", TIFF.IMAGE_LENGTH, XMPTIFF.PIXEL_Y_DIMENSION);
+
+ // PDF-only namespaces: single canonical home in PDF.*, no dc-style duplicate-key question.
+ put("http://www.aiim.org/pdfa/ns/id/", "part", PDF.PDFAID_PART);
+ put("http://www.aiim.org/pdfa/ns/id/", "conformance", PDF.PDFAID_CONFORMANCE);
+ put("http://www.aiim.org/pdfua/ns/id/", "part", PDF.PDFUAID_PART);
+ put("http://ns.adobe.com/pdfx/1.3/", "GTS_PDFXVersion", PDF.PDFX_VERSION);
+ put("http://ns.adobe.com/pdfx/1.3/", "GTS_PDFXConformance", PDF.PDFX_CONFORMANCE);
+ put("http://www.npes.org/pdfx/ns/id/", "GTS_PDFXVersion", PDF.PDFXID_VERSION);
+ put("http://www.npes.org/pdfvt/ns/id/", "GTS_PDFVTVersion", PDF.PDFVT_VERSION);
+ put("http://www.npes.org/pdfvt/ns/id/", "GTS_PDFVTModDate", PDF.PDFVT_MODIFIED);
+ put("http://ns.adobe.com/illustrator/1.0/", "Type", PDF.ILLUSTRATOR_TYPE);
+
+ HISTORY.put("action", XMPMM.HISTORY_ACTION);
+ HISTORY.put("when", XMPMM.HISTORY_WHEN);
+ HISTORY.put("instanceID", XMPMM.HISTORY_EVENT_INSTANCEID);
+ HISTORY.put("softwareAgent", XMPMM.HISTORY_SOFTWARE_AGENT);
+ HISTORY.put("changed", XMPMM.HISTORY_CHANGED);
+ HISTORY.put("parameters", XMPMM.HISTORY_PARAMETERS);
+
+ DERIVED_FROM.put("documentID", XMPMM.DERIVED_FROM_DOCUMENTID);
+ DERIVED_FROM.put("instanceID", XMPMM.DERIVED_FROM_INSTANCEID);
+ DERIVED_FROM.put("originalDocumentID", XMPMM.DERIVED_FROM_ORIGINAL_DOCUMENTID);
+ DERIVED_FROM.put("renditionClass", XMPMM.DERIVED_FROM_RENDITION_CLASS);
+
+ GOOGLE_CAMERA.put("MotionPhoto", Google.MOTION_PHOTO);
+ GOOGLE_CAMERA.put("MotionPhotoVersion", Google.MOTION_PHOTO_VERSION);
+ GOOGLE_CAMERA.put("MotionPhotoPresentationTimestampUs", Google.MOTION_PHOTO_PRESENTATION_TIMESTAMP_US);
+ GOOGLE_CAMERA.put("MicroVideo", Google.MICRO_VIDEO);
+ GOOGLE_CAMERA.put("MicroVideoVersion", Google.MICRO_VIDEO_VERSION);
+ GOOGLE_CAMERA.put("MicroVideoOffset", Google.MICRO_VIDEO_OFFSET);
+ GOOGLE_CAMERA.put("MicroVideoPresentationTimestampUs", Google.MICRO_VIDEO_PRESENTATION_TIMESTAMP_US);
+
+ // Standard XMP dates fill dcterms:created/modified set-if-absent, so an XMP-only file (no
+ // docinfo or binary EXIF date) isn't left dateless; photoshop:/exif: dates are fallbacks.
+ FILL_IF_ABSENT.put(xmp + " CreateDate", TikaCoreProperties.CREATED);
+ FILL_IF_ABSENT.put(xmp + " ModifyDate", TikaCoreProperties.MODIFIED);
+ FILL_IF_ABSENT.put(ps + " DateCreated", TikaCoreProperties.CREATED); // fallback creation date
+ FILL_IF_ABSENT.put(exif + " DateTimeOriginal", TikaCoreProperties.CREATED);
+
+ // NativeDigest is Adobe's XMP<->legacy-EXIF/TIFF sync check (tag-ids + MD5); useless to consumers.
+ DROP.add("http://ns.adobe.com/tiff/1.0/ NativeDigest");
+ DROP.add("http://ns.adobe.com/exif/1.0/ NativeDigest");
+ // TODO: base64 JPEG thumbnail -> route through embedded-doc extraction. For now drop the blob
+ // (it bloats every dump) but keep the xmpGImg:width/height/format siblings so it stays visible.
+ DROP.add("http://ns.adobe.com/xap/1.0/g/img/ image");
+ }
+
+ private final XmpSaxFlattener flattener = new XmpSaxFlattener();
+ // container-specific value fixup (e.g. PDF octal-BOM decode); identity for XMP that needs none
+ private final UnaryOperator valueDecoder;
+
+ public XmpExtractor() {
+ this(UnaryOperator.identity());
+ }
+
+ public XmpExtractor(UnaryOperator valueDecoder) {
+ this.valueDecoder = valueDecoder == null ? UnaryOperator.identity() : valueDecoder;
+ }
+
+ public void extract(byte[] packet, Metadata metadata)
+ throws IOException, TikaException, SAXException {
+ extract(packet, metadata, new ParseContext());
+ }
+
+ public void extract(byte[] packet, Metadata metadata, ParseContext context)
+ throws IOException, TikaException, SAXException {
+ map(flattener.flatten(packet, context), metadata);
+ }
+
+ public void extract(InputStream packet, Metadata metadata, ParseContext context)
+ throws IOException, TikaException, SAXException {
+ map(flattener.flatten(packet, context), metadata);
+ }
+
+ private void map(List props, Metadata metadata) {
+ // History and rdf:Alt lang sets are handled as groups (aligned bags; Alt canonical = x-default);
+ // everything else streams one leaf at a time.
+ emitHistory(props, metadata);
+ emitLangAlternatives(props, metadata);
+ for (XmpProperty p : props) {
+ if (!isMappedHistoryEvent(p) && !isLangAlternative(p)) {
+ map(p, metadata);
+ }
+ }
+ }
+
+ private boolean isMappedHistoryEvent(XmpProperty p) {
+ return RESOURCE_EVENT_NS.equals(p.namespaceURI) && p.path.contains("History[")
+ && HISTORY.containsKey(p.localName());
+ }
+
+ /** A mapped, top-level, language-tagged leaf is an rdf:Alt alternative (dc:title, ...). */
+ private boolean isLangAlternative(XmpProperty p) {
+ return p.lang != null && !p.lang.isEmpty() && p.path.indexOf('/') < 0
+ && TABLE.containsKey(p.namespaceURI + " " + p.localName());
+ }
+
+ private static int historyIndex(String path) {
+ int i = path.indexOf("History[");
+ if (i < 0) {
+ return -1;
+ }
+ int start = i + "History[".length();
+ int end = path.indexOf(']', start);
+ if (end < 0) {
+ return -1;
+ }
+ try {
+ return Integer.parseInt(path.substring(start, end));
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /** Emit the xmpMM:History parallel bags per event, index-aligned, capped, dates normalized. */
+ private void emitHistory(List props, Metadata metadata) {
+ TreeMap> events = new TreeMap<>();
+ for (XmpProperty p : props) {
+ if (!isMappedHistoryEvent(p)) {
+ continue;
+ }
+ int idx = historyIndex(p.path);
+ String value = valueDecoder.apply(p.value);
+ if (idx < 0 || value == null || value.isEmpty()) {
+ continue;
+ }
+ if ("when".equals(p.localName())) { // a text bag that holds a date -> normalize it
+ String n = XmpDates.normalize(value);
+ if (n != null) {
+ value = n;
+ }
+ }
+ events.computeIfAbsent(idx, k -> new HashMap<>()).put(HISTORY.get(p.localName()), value);
+ }
+ // only emit the bags that actually occur, but keep those index-aligned across events
+ Set present = new LinkedHashSet<>();
+ for (Map ev : events.values()) {
+ present.addAll(ev.keySet());
+ }
+ int count = 0;
+ for (Map ev : events.values()) {
+ if (++count > MAX_HISTORY_EVENTS) {
+ break;
+ }
+ for (Property hp : HISTORY_PROPS) {
+ if (present.contains(hp)) {
+ metadata.add(hp, ev.getOrDefault(hp, ""));
+ }
+ }
+ }
+ }
+
+ /** rdf:Alt language sets: the canonical value is x-default (else first); every lang keeps a key. */
+ private void emitLangAlternatives(List props, Metadata metadata) {
+ Map> byKey = new LinkedHashMap<>();
+ for (XmpProperty p : props) {
+ if (isLangAlternative(p)) {
+ byKey.computeIfAbsent(p.namespaceURI + " " + p.localName(),
+ k -> new ArrayList<>()).add(p);
+ }
+ }
+ for (Map.Entry> e : byKey.entrySet()) {
+ List alts = e.getValue();
+ XmpProperty primary = alts.get(0);
+ for (XmpProperty a : alts) {
+ if ("x-default".equals(a.lang)) {
+ primary = a;
+ break;
+ }
+ }
+ String primaryValue = valueDecoder.apply(primary.value);
+ for (Property prop : TABLE.get(e.getKey())) {
+ if (prop.isMultiValuePermitted()) {
+ // a text bag keeps every language on the canonical key (TIKA-1295 / TIKA-4466),
+ // but x-default leads so metadata.get(prop) -- which returns values[0] -- yields
+ // the default, not whatever language the packet happened to list first.
+ if (primaryValue != null && !primaryValue.isEmpty()) {
+ emit(metadata, prop, primaryValue);
+ }
+ for (XmpProperty a : alts) {
+ if (a == primary) {
+ continue;
+ }
+ String v = valueDecoder.apply(a.value);
+ if (v != null && !v.isEmpty()) {
+ emit(metadata, prop, v);
+ }
+ }
+ } else if (primaryValue != null && !primaryValue.isEmpty()) {
+ emit(metadata, prop, primaryValue); // single-valued: x-default, not last-wins
+ }
+ for (XmpProperty a : alts) { // every language variant keeps its own key
+ String v = valueDecoder.apply(a.value);
+ if (v != null && !v.isEmpty()) {
+ metadata.add(prop.getName() + ":" + a.lang, valueFor(prop, v));
+ }
+ }
+ }
+ }
+ }
+
+ private void map(XmpProperty p, Metadata metadata) {
+ String uri = p.namespaceURI;
+ String ln = p.localName();
+
+ if (XmpSaxFlattener.XMLNS.equals(uri)) {
+ return; // xml:lang qualifier leaf — structural, not content
+ }
+ if (DROP.contains(uri + " " + ln)) {
+ return; // Adobe-internal digest or base64 thumbnail blob: neither mapped nor raw
+ }
+ String value = valueDecoder.apply(p.value); // container-specific fixup (e.g. PDF octal-BOM)
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+ // xmpMM:History is handled as a group in emitHistory() (parallel bags stay aligned).
+ if (RESOURCE_REF_NS.equals(uri) && p.path.contains("DerivedFrom")) {
+ Property dp = DERIVED_FROM.get(ln);
+ if (dp != null) {
+ emit(metadata, dp, value);
+ return;
+ } // unmapped ref field (renditionClass, ...) -> passthrough below
+ }
+ if (Google.CAMERA_NS.equals(uri)) {
+ Property g = GOOGLE_CAMERA.get(ln);
+ if (g != null) {
+ if (metadata.get(g) == null) {
+ metadata.set(g, value);
+ }
+ return;
+ }
+ }
+ // Map (uri, localName) only at the top level: a property nested in a struct (e.g. xmpMM:InstanceID
+ // inside xmpMM:Pantry) must not overwrite the document-level one. Struct segments join with '/',
+ // array indices don't, so top-level has no '/'; nested falls through to raw passthrough.
+ boolean topLevel = p.path.indexOf('/') < 0;
+ Property fill = FILL_IF_ABSENT.get(uri + " " + ln);
+ if (topLevel && fill != null && metadata.get(fill) == null) {
+ metadata.set(fill, valueFor(fill, value)); // canonical date, only if none yet
+ }
+ // Language alternatives (leaves with an xml:lang) are handled in emitLangAlternatives().
+ Property[] props = TABLE.get(uri + " " + ln);
+ if (topLevel && props != null) {
+ for (Property prop : props) {
+ emit(metadata, prop, value);
+ }
+ return;
+ }
+ // unmapped: namespaced raw passthrough; a trailing array index collapses to a multi-valued key
+ metadata.add(RAW_PREFIX + TRAILING_INDEX.matcher(p.path).replaceFirst(""), value);
+ }
+
+ private void emit(Metadata metadata, Property prop, String value) {
+ if (prop == null) {
+ return;
+ }
+ String v = valueFor(prop, value);
+ if (prop.isMultiValuePermitted()) {
+ metadata.add(prop, v);
+ } else {
+ metadata.set(prop, v);
+ }
+ }
+
+ private static String valueFor(Property prop, String value) {
+ if (prop.getValueType() == Property.ValueType.DATE) {
+ String norm = XmpDates.normalize(value);
+ if (norm != null) {
+ return norm;
+ }
+ }
+ return value;
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java
new file mode 100644
index 00000000000..2267b462c22
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+/** One flattened XMP leaf: namespace URI, xmpcore-style path (prefix:name with [i] indices), value, xml:lang. */
+public final class XmpProperty {
+
+ public final String namespaceURI;
+ public final String path;
+ public final String value;
+ public final String lang; // xml:lang qualifier of a lang-alt item, or null
+
+ public XmpProperty(String namespaceURI, String path, String value) {
+ this(namespaceURI, path, value, null);
+ }
+
+ public XmpProperty(String namespaceURI, String path, String value, String lang) {
+ this.namespaceURI = namespaceURI == null ? "" : namespaceURI;
+ this.path = path;
+ this.value = value;
+ this.lang = lang;
+ }
+
+ /** Local name of the leaf, e.g. {@code dc:creator[1]} -> {@code creator}. */
+ public String localName() {
+ int slash = path.lastIndexOf('/');
+ String last = slash < 0 ? path : path.substring(slash + 1);
+ last = last.replaceAll("\\[\\d+\\]$", ""); // drop trailing array index
+ int colon = last.lastIndexOf(':');
+ return colon < 0 ? last : last.substring(colon + 1);
+ }
+
+ @Override
+ public String toString() {
+ return path + "=" + value;
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java
new file mode 100644
index 00000000000..f0e1506b23e
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.commons.io.input.CloseShieldInputStream;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.utils.XMLReaderUtils;
+
+/** SAX-flattens an RDF/XML XMP packet to xmpcore-style (uri, path, value) leaves. */
+public final class XmpSaxFlattener {
+
+ static final String RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+ static final String XMLNS = "http://www.w3.org/XML/1998/namespace";
+ static final String META = "adobe:ns:meta/"; // x:xmpmeta/x:xapmeta wrapper + x:xmptk
+
+ public List flatten(byte[] packet) throws IOException, TikaException, SAXException {
+ return flatten(packet, new ParseContext());
+ }
+
+ public List flatten(byte[] packet, ParseContext context)
+ throws IOException, TikaException, SAXException {
+ try (InputStream is = new ByteArrayInputStream(packet)) {
+ return flatten(is, context);
+ }
+ }
+
+ public List flatten(InputStream packet, ParseContext context)
+ throws IOException, TikaException, SAXException {
+ Handler h = new Handler();
+ // hardened pooled SAX (secure factory + entity cap + OfflineContentHandler); CloseShield the stream
+ XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(packet), h, context);
+ return h.out;
+ }
+
+ private static final class Handler extends DefaultHandler {
+ // Caps so a hostile packet can't inflate metadata: leaf count, key/path length, value length.
+ static final int MAX_LEAVES = 50_000;
+ static final int MAX_PATH = 512;
+ static final int MAX_VALUE = 1 << 20; // 1 MB
+ final List out = new ArrayList<>();
+ final ArrayDeque segs = new ArrayDeque<>();
+ final ArrayDeque uris = new ArrayDeque<>(); // leaf/property namespace per frame
+ final ArrayDeque liCount = new ArrayDeque<>();
+ final ArrayDeque text = new ArrayDeque<>();
+ final ArrayDeque childCount = new ArrayDeque<>();
+ final ArrayDeque langs = new ArrayDeque<>(); // xml:lang per frame (holder so it can be set after push)
+ final ArrayDeque hasValue = new ArrayDeque<>(); // frame carries an explicit rdf:value
+
+ static boolean isContainer(String u, String l) {
+ return RDF.equals(u) && (l.equals("Bag") || l.equals("Seq") || l.equals("Alt"));
+ }
+
+ void add(XmpProperty p) {
+ if (out.size() >= MAX_LEAVES || p.path.length() > MAX_PATH
+ || (p.value != null && p.value.length() > MAX_VALUE)) {
+ return; // drop over-cap leaves: count / absurd key / bloated value
+ }
+ out.add(p);
+ }
+
+ String path() {
+ StringBuilder p = new StringBuilder();
+ Iterator it = segs.descendingIterator();
+ while (it.hasNext()) {
+ String s = it.next();
+ if (s.startsWith("[")) {
+ p.append(s);
+ } else {
+ if (p.length() > 0) {
+ p.append('/');
+ }
+ p.append(s);
+ }
+ }
+ return p.toString();
+ }
+
+ void pushFrame(String seg, String uri) {
+ if (!childCount.isEmpty()) {
+ childCount.peek()[0]++;
+ }
+ segs.push(seg);
+ uris.push(uri == null ? "" : uri);
+ text.push(new StringBuilder());
+ childCount.push(new int[]{0});
+ langs.push(new String[]{null});
+ hasValue.push(new boolean[]{false});
+ }
+
+ @Override
+ public void startElement(String u, String l, String qn, Attributes a) {
+ if (META.equals(u)) {
+ return;
+ }
+ boolean rdf = RDF.equals(u);
+ if (rdf && l.equals("RDF")) {
+ return;
+ }
+ if (rdf && l.equals("Description")) {
+ emitAttrs(a, false);
+ return;
+ }
+ if (isContainer(u, l)) {
+ liCount.push(new int[]{0});
+ return;
+ }
+ if (rdf && l.equals("li")) {
+ // a bare rdf:li outside a Bag/Seq/Alt (malformed) has no counter -> treat as [1]
+ int idx = liCount.isEmpty() ? 1 : ++liCount.peek()[0];
+ pushFrame("[" + idx + "]", uris.isEmpty() ? "" : uris.peek());
+ } else if (rdf && l.equals("value")) {
+ // rdf:value holds the frame's value even when qualifier siblings are present
+ if (!hasValue.isEmpty()) {
+ hasValue.peek()[0] = true;
+ }
+ return;
+ } else if (!rdf) {
+ pushFrame(qn, u);
+ } else {
+ return;
+ }
+ emitAttrs(a, true);
+ }
+
+ void emitAttrs(Attributes a, boolean valueFrame) {
+ String base = path();
+ for (int i = 0; i < a.getLength(); i++) {
+ String au = a.getURI(i);
+ String al = a.getLocalName(i);
+ String aq = a.getQName(i);
+ String v = a.getValue(i);
+ if (v == null) {
+ continue;
+ }
+ v = v.trim();
+ if (v.isEmpty()) {
+ continue;
+ }
+ if (XMLNS.equals(au)) {
+ if (al.equals("lang")) {
+ if (valueFrame) {
+ langs.peek()[0] = v; // attach to this value's frame
+ }
+ if (!base.isEmpty()) {
+ add(new XmpProperty(XMLNS, base + "/xml:lang", v));
+ }
+ }
+ continue;
+ }
+ if (RDF.equals(au)) {
+ if (al.equals("resource")) {
+ add(new XmpProperty(uris.isEmpty() ? "" : uris.peek(), base, v));
+ } else if (al.equals("about")) { // empty rdf:about already skipped above
+ add(new XmpProperty(RDF, base.isEmpty() ? "rdf:about" : base + "/rdf:about", v));
+ }
+ continue;
+ }
+ if (au == null || au.isEmpty()) {
+ continue;
+ }
+ add(new XmpProperty(au, base.isEmpty() ? aq : base + "/" + aq, v));
+ }
+ }
+
+ @Override
+ public void characters(char[] c, int s, int len) {
+ if (!text.isEmpty()) {
+ text.peek().append(c, s, len);
+ }
+ }
+
+ @Override
+ public void endElement(String u, String l, String qn) {
+ if (META.equals(u)) {
+ return;
+ }
+ boolean rdf = RDF.equals(u);
+ if (rdf && l.equals("RDF")) {
+ return;
+ }
+ if (rdf && l.equals("Description")) {
+ return;
+ }
+ if (isContainer(u, l)) {
+ liCount.pop();
+ return;
+ }
+ if (rdf && !l.equals("li")) {
+ return;
+ }
+ String t = text.peek().toString().trim();
+ // a frame is a leaf when it has no child elements, or when an rdf:value gave it a value
+ if (!t.isEmpty() && (childCount.peek()[0] == 0 || hasValue.peek()[0])) {
+ add(new XmpProperty(uris.peek(), path(), t, langs.peek()[0]));
+ }
+ segs.pop();
+ uris.pop();
+ text.pop();
+ childCount.pop();
+ langs.pop();
+ hasValue.pop();
+ }
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java
deleted file mode 100644
index 976d9e601f3..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.xmp;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.TikaTest;
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.io.TikaInputStream;
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.metadata.XMPMM;
-
-public class JempboxExtractorTest extends TikaTest {
-
- @Test
- public void testParseJpeg() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testJPEG_commented.jpg")) {
- // set some values before extraction to see that they are overridden
- metadata.set(TikaCoreProperties.TITLE, "old title");
- metadata.set(TikaCoreProperties.DESCRIPTION, "old description");
- metadata.set(TikaCoreProperties.CREATOR, "previous author");
- // ... or kept in case the field is multi-value
- metadata.add(TikaCoreProperties.SUBJECT, "oldkeyword");
-
- JempboxExtractor extractor = new JempboxExtractor(metadata);
- extractor.parse(tis);
-
- // DublinCore fields
- assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE));
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("oldkeyword"));
- assertTrue(keywords.contains("grazelands"));
- assertTrue(keywords.contains("nature reserve"));
- assertTrue(keywords.contains("bird watching"));
- assertTrue(keywords.contains("coast"));
- }
- }
-
- @Test
- public void testParseJpegPhotoshop() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream(
- "/test-documents/testJPEG_commented_pspcs2mac.jpg")) {
- JempboxExtractor extractor = new JempboxExtractor(metadata);
- extractor.parse(tis);
-
- // DublinCore fields
- assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE));
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("bird watching"));
- assertTrue(keywords.contains("coast"));
- }
- }
-
- @Test
- public void testParseJpegXnviewmp() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream(
- "/test-documents/testJPEG_commented_xnviewmp026.jpg")) {
- JempboxExtractor extractor = new JempboxExtractor(metadata);
- extractor.parse(tis);
-
- // XnViewMp fields not understood by Jempbox
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("coast"));
- assertTrue(keywords.contains("nature reserve"));
- }
- }
-
- @Test
- public void testJoinCreators() {
- assertEquals("Mr B", new JempboxExtractor(null).joinCreators(Collections.singletonList("Mr B")));
- // TODO use multi-value property instead?
- assertEquals("Mr B, Mr A",
- new JempboxExtractor(null).joinCreators(Arrays.asList("Mr B", "Mr A")));
- }
-
- @Test
- public void testMaxXMPMMHistory() throws Exception {
- int maxHistory = JempboxExtractor.getMaxXMPMMHistory();
- try {
- Metadata m = new Metadata();
- JempboxExtractor ex = new JempboxExtractor(m);
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) {
- ex.parse(tis);
- }
- assertEquals(7, m.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length);
-
- JempboxExtractor.setMaxXMPMMHistory(5);
- m = new Metadata();
- ex = new JempboxExtractor(m);
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) {
- ex.parse(tis);
- }
- assertEquals(5, m.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length);
- } finally {
- //if something goes wrong, make sure to set this back to what it was
- JempboxExtractor.setMaxXMPMMHistory(maxHistory);
- }
- }
-
- @Test
- public void testModifiedTZ() throws Exception {
- Metadata m = new Metadata();
- JempboxExtractor ex = new JempboxExtractor(m);
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) {
- ex.parse(tis);
- }
- assertEquals("2014-03-04T22:50:41Z", m.get(XMPMM.HISTORY_WHEN));
- }
-
- @Test
- public void testXMPMMMisc() throws Exception {
- Metadata m = new Metadata();
- JempboxExtractor ex = new JempboxExtractor(m);
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) {
- ex.parse(tis);
- }
- assertEquals("uuid:cccee1fc-51b3-4b52-ac86-672af3974d25", m.getValues(XMPMM.DOCUMENTID)[0]);
- assertEquals("uuid:afa71b09-7cc5-48ac-8664-ac6dcf8b5ab4", m.getValues(XMPMM.INSTANCEID)[0]);
- }
-}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java
new file mode 100644
index 00000000000..e13fa2b4bfe
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+public class XmpDatesTest {
+
+ /** Timezone-bearing dates have a deterministic UTC instant. */
+ @Test
+ public void testOffsetDatesNormalizeToUtc() {
+ assertEquals("2013-10-21T08:29:53Z", XmpDates.normalize("2013-10-21T10:29:53+02:00"));
+ assertEquals("2014-05-01T22:27:00Z", XmpDates.normalize("2014-05-01T15:27-07:00"));
+ assertEquals("2006-03-15T15:20:00Z", XmpDates.normalize("2006-03-15T15:20Z"));
+ }
+
+ /** Minute-precision-with-offset (no seconds): jempbox silently corrupts this; PDFBox is correct. */
+ @Test
+ public void testMinutePrecisionWithOffset() {
+ assertEquals("2016-06-22T09:59:00Z", XmpDates.normalize("2016-06-22T17:59+08:00"));
+ assertEquals("2010-03-12T06:09:00Z", XmpDates.normalize("2010-03-12T11:09+05:00"));
+ }
+
+ /** Fractional seconds parse (jempbox drops them) and truncate to seconds. */
+ @Test
+ public void testFractionalSeconds() {
+ assertNotNull(XmpDates.normalize("2017-12-18T17:28:56.425+00:00"));
+ assertEquals("2018-11-04T09:29:04Z", XmpDates.normalize("2018-11-04T10:29:04.3218973+01:00"));
+ }
+
+ /** PDF D: form and date-only forms are recognized. */
+ @Test
+ public void testOtherRecognizedForms() {
+ assertNotNull(XmpDates.normalize("D:20030101120000+05'30'"));
+ assertNotNull(XmpDates.normalize("2015-06-12"));
+ assertNotNull(XmpDates.normalize("2015:06:12")); // EXIF date-only, via DateUtils fallback
+ }
+
+ /** Non-dates degrade to null so the caller can pass the raw value through. */
+ @Test
+ public void testGarbageReturnsNull() {
+ assertNull(XmpDates.normalize("CPY Document Creation Date"));
+ assertNull(XmpDates.normalize(""));
+ assertNull(XmpDates.normalize(null));
+ }
+
+ /**
+ * A partial date (YYYY, YYYY-MM) inflates to a full timestamp; exact-equality (not assertNotNull)
+ * pins that fabricated precision so the value-representation fix trips this test.
+ */
+ @Test
+ public void testPartialDatesInflateToFullTimestamp() {
+ assertEquals("2019-01-01T00:00:00Z", XmpDates.normalize("2019"));
+ assertEquals("2019-06-01T00:00:00Z", XmpDates.normalize("2019-06"));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java
new file mode 100644
index 00000000000..edb5e3c4578
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java
@@ -0,0 +1,483 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.PDF;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.Photoshop;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.XMP;
+import org.apache.tika.metadata.XMPDC;
+import org.apache.tika.metadata.XMPMM;
+import org.apache.tika.metadata.XMPTIFF;
+
+public class XmpExtractorTest {
+
+ private static final String PACKET =
+ ""
+ + ""
+ + ""
+ + " 2020-01-02T03:04:05Z"
+ + " AliceBob"
+ + " Hello"
+ + " "
+ + " docid-123"
+ + " orig-999"
+ + " proof:pdf"
+ + " "
+ + " "
+ + " created"
+ + " /metadata"
+ + " from application/pdf"
+ + " saved"
+ + " "
+ + "";
+
+ private Metadata metadata;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ metadata = new Metadata();
+ new XmpExtractor().extract(PACKET.getBytes(UTF_8), metadata);
+ }
+
+ @Test
+ public void testScalarsAndDates() {
+ assertEquals("Hello", metadata.get(TikaCoreProperties.TITLE));
+ assertEquals("2020-01-02T03:04:05Z", metadata.get(XMP.CREATE_DATE));
+ // xmp:CreateDate also fills the canonical created date when nothing else set it
+ assertEquals("2020-01-02T03:04:05Z", metadata.get(TikaCoreProperties.CREATED));
+ }
+
+ /** Canonical created is filled set-if-absent: a doc date (docinfo/EXIF) already present wins. */
+ @Test
+ public void testCanonicalCreatedNotOverwritten() throws Exception {
+ Metadata md = new Metadata();
+ md.set(TikaCoreProperties.CREATED, "1999-12-31T00:00:00Z"); // e.g. docinfo/EXIF got here first
+ new XmpExtractor().extract(PACKET.getBytes(UTF_8), md);
+ assertEquals("1999-12-31T00:00:00Z", md.get(TikaCoreProperties.CREATED)); // unchanged
+ assertEquals("2020-01-02T03:04:05Z", md.get(XMP.CREATE_DATE)); // xmp key still set
+ }
+
+ @Test
+ public void testMultiValued() {
+ assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(TikaCoreProperties.CREATOR));
+ }
+
+ @Test
+ public void testStructs() {
+ assertEquals("docid-123", metadata.get(XMPMM.DERIVED_FROM_DOCUMENTID));
+ assertArrayEquals(new String[]{"created", "saved"}, metadata.getValues(XMPMM.HISTORY_ACTION));
+ }
+
+ /** History/DerivedFrom subfields beyond action/when/documentID map to dedicated properties. */
+ @Test
+ public void testHistoryAndDerivedFromSubfields() {
+ assertEquals("/metadata", metadata.get(XMPMM.HISTORY_CHANGED));
+ assertEquals("from application/pdf", metadata.get(XMPMM.HISTORY_PARAMETERS));
+ assertEquals("orig-999", metadata.get(XMPMM.DERIVED_FROM_ORIGINAL_DOCUMENTID));
+ assertEquals("proof:pdf", metadata.get(XMPMM.DERIVED_FROM_RENDITION_CLASS));
+ }
+
+ @Test
+ public void testUnmappedPassthrough() {
+ assertEquals("25", metadata.get("xmp-raw:crs:Contrast")); // still-unmapped -> namespaced raw
+ assertEquals("Canon", metadata.get(TIFF.EQUIPMENT_MAKE)); // tiff:Make is now promoted
+ }
+
+ /** Group-1 + Group-2(clean) + aux promotions: top raw keys map to first-class properties. */
+ @Test
+ public void testPromotedKeys() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+
+ // group 1
+ assertEquals("3", md.get(Photoshop.COLOR_MODE));
+ assertEquals("sRGB IEC61966-2.1", md.get(Photoshop.ICC_PROFILE));
+ assertEquals("7", md.get(PagedText.N_PAGES));
+ assertEquals("False", md.get(PDF.TRAPPED));
+ assertNotNull(md.get(Photoshop.DATE_CREATED)); // date-typed, normalized
+ assertNotNull(md.get(TikaCoreProperties.CREATED)); // DateCreated fills created
+ // group 2 (clean exif/tiff) + aux -> the shared TIFF interface
+ assertEquals("Canon", md.get(TIFF.EQUIPMENT_MAKE));
+ assertEquals("EOS 5D", md.get(TIFF.EQUIPMENT_MODEL));
+ assertEquals("1", md.get(TIFF.ORIENTATION));
+ assertNotNull(md.get(TIFF.ORIGINAL_DATE)); // exif:DateTimeOriginal, normalized
+ assertEquals("64", md.get(TIFF.ISO_SPEED_RATINGS));
+ assertEquals("142026001685", md.get(TIFF.SERIAL_NUMBER));
+ assertEquals("iPhone 5s back camera 4.12mm f/2.2", md.get(TIFF.LENS));
+ // exif:Pixel*Dimension folds into the canonical IMAGE_WIDTH/LENGTH (+ xmp-marked variant)
+ assertEquals("600", md.get(TIFF.IMAGE_WIDTH));
+ assertEquals("734", md.get(TIFF.IMAGE_LENGTH));
+ assertEquals("600", md.get(XMPTIFF.PIXEL_X_DIMENSION));
+ // tiff:/exif: are double-keyed: the XMP-marked variant preserves provenance
+ assertEquals("Canon", md.get(XMPTIFF.EQUIPMENT_MAKE));
+ assertEquals("EOS 5D", md.get(XMPTIFF.EQUIPMENT_MODEL));
+ assertNotNull(md.get(XMPTIFF.ORIGINAL_DATE));
+ // aux: is single-key (no binary source) -> no xmp-marked variant
+ assertNull(md.get("xmp:aux:Lens"));
+ // no longer raw
+ assertNull(md.get("xmp-raw:photoshop:ColorMode"));
+ assertNull(md.get("xmp-raw:tiff:Make"));
+ assertNull(md.get("xmp-raw:aux:Lens"));
+ }
+
+ /** A bare rdf:li outside a Bag/Seq/Alt (malformed) must not NPE / crash the parse. */
+ @Test
+ public void testBareRdfLiIsNotFatal() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + "lonely" // rdf:li with no container
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md); // must not throw
+ assertEquals("lonely", md.get(TikaCoreProperties.SUBJECT));
+ }
+
+ /** A property nested in a struct must not overwrite the document-level one (falls to raw). */
+ @Test
+ public void testNestedPropertyDoesNotClobberDocumentLevel() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + " "
+ + " pantry-ingredient"
+ + " "
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ assertEquals("doc-level", md.get(XMPMM.INSTANCEID)); // not clobbered by the nested one
+ assertNotNull(md.get("xmp-raw:xmpMM:Pantry[1]/xmpMM:InstanceID")); // nested falls to raw
+ }
+
+ /** rdf:value carries the property's value even when qualifier siblings are present. */
+ @Test
+ public void testRdfValueWithQualifier() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + " "
+ + " the-id"
+ + " myscheme"
+ + " "
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ assertEquals("the-id", md.get(XMP.IDENTIFIER)); // value kept despite the qualifier sibling
+ }
+
+ /** dc:title is a text bag: every language accumulates on the canonical key (TIKA-1295/4466). */
+ @Test
+ public void testMultiLangTitleAccumulates() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + ""
+ + "Bonjour"
+ + "Hello"
+ + "Hola"
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ // x-default ("Hello") leads the bag even though it is listed second in the packet, so
+ // metadata.get(TITLE) (== values[0]) is the default; the rest follow in document order.
+ assertArrayEquals(new String[]{"Hello", "Bonjour", "Hola"}, md.getValues(TikaCoreProperties.TITLE));
+ assertEquals("Hello", md.get(TikaCoreProperties.TITLE));
+ assertEquals("Bonjour", md.get("dc:title:fr"));
+ assertEquals("Hello", md.get("dc:title:x-default"));
+ assertEquals("Hola", md.get("dc:title:es"));
+ }
+
+ /** A single-valued Alt property (xmp:Title -> XMP.TITLE) takes x-default, not the last li. */
+ @Test
+ public void testSingleValuedLangAltIsXDefault() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + ""
+ + "Titre"
+ + "Title"
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ assertEquals("Title", md.get(XMP.TITLE)); // x-default, not the last alternative (Titre)
+ }
+
+ /** xmpMM:History parallel bags stay index-aligned: a missing field is padded so ACTION[i]~WHEN[i]. */
+ @Test
+ public void testHistoryParallelArraysStayAligned() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + ""
+ + "created"
+ + " 2020-01-01T00:00:00Z"
+ + "saved"
+ + "printed"
+ + " 2021-02-02T00:00:00Z"
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ assertArrayEquals(new String[]{"created", "saved", "printed"},
+ md.getValues(XMPMM.HISTORY_ACTION));
+ // the second event ("saved") has no when, so WHEN[1] is padded to keep the arrays aligned
+ assertArrayEquals(new String[]{"2020-01-01T00:00:00Z", "", "2021-02-02T00:00:00Z"},
+ md.getValues(XMPMM.HISTORY_WHEN));
+ }
+
+ /** Adobe-internal digests and the base64 thumbnail blob are dropped, not exposed even as raw. */
+ @Test
+ public void testJunkKeysDropped() throws Exception {
+ String packet = ""
+ + ""
+ + ""
+ + " 256,257,258;A1B2C3"
+ + " 36864,40960;123456"
+ + " "
+ + " /9j/4AAQSkZJRgABAgEBLAEsAAD"
+ + " 256"
+ + " JPEG"
+ + " "
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+
+ boolean nativeDigest = false;
+ boolean blob = false;
+ boolean widthKept = false;
+ for (String n : md.names()) {
+ if (n.contains("NativeDigest")) {
+ nativeDigest = true;
+ }
+ if (md.get(n).startsWith("/9j/")) {
+ blob = true;
+ }
+ if (n.endsWith("xmpGImg:width")) {
+ widthKept = true;
+ }
+ }
+ assertFalse(nativeDigest, "tiff/exif:NativeDigest must be dropped");
+ assertFalse(blob, "base64 thumbnail blob must be dropped");
+ assertTrue(widthKept, "harmless thumbnail siblings are kept");
+ }
+
+ /** Non-ASCII UTF-8 values must survive the SAX flatten/extract path intact. */
+ @Test
+ public void testNonAsciiUtf8() throws Exception {
+ String xmp =
+ ""
+ + ""
+ + ""
+ + ""
+ + "Tosteberga Ängar"
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(xmp.getBytes(java.nio.charset.StandardCharsets.UTF_8), md);
+ assertEquals("Tosteberga Ängar", md.get(TikaCoreProperties.TITLE));
+ }
+
+ /** Raw passthrough is namespaced (xmp-raw:) so untrusted XMP can never shadow a known Tika field. */
+ @Test
+ public void testPassthroughCannotShadowKnownFields() throws Exception {
+ String evil =
+ ""
+ + " table miss -> raw
+ + " xmlns:ev='http://evil.example/2'>"
+ + ""
+ + " injected"
+ + " ok"
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(evil.getBytes(UTF_8), md);
+ assertNull(md.get(TikaCoreProperties.TITLE), "spoofed dc:title must not write the known field");
+ assertNull(md.get("dc:title"));
+ assertEquals("injected", md.get("xmp-raw:dc:title")); // it lands under the raw namespace instead
+ assertEquals("ok", md.get("xmp-raw:ev:harmless"));
+ }
+
+ /**
+ * A raw (unmapped) bag collapses to one multi-valued xmp-raw key; interior indices in an
+ * array-of-structs are kept so element fields stay distinguishable.
+ */
+ @Test
+ public void testRawArrayKeys() throws Exception {
+ String packet =
+ ""
+ + ""
+ + ""
+ + ""
+ + "alphabetagamma"
+ + ""
+ + ""
+ + "e1"
+ + "e2"
+ + ""
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ // bag -> one multi-valued key, no per-index keys
+ assertArrayEquals(new String[]{"alpha", "beta", "gamma"}, md.getValues("xmp-raw:foo:Tags"));
+ assertNull(md.get("xmp-raw:foo:Tags[1]"));
+ // array-of-structs -> interior indices preserved so e1/e2 stay separate
+ assertEquals("e1", md.get("xmp-raw:foo:Events[1]/foo:name"));
+ assertEquals("e2", md.get("xmp-raw:foo:Events[2]/foo:name"));
+ }
+
+ /** Option A: dc values land in both the canonical and the xmp-namespaced property. */
+ @Test
+ public void testDoubleKeying() {
+ assertEquals("Hello", metadata.get(XMPDC.TITLE));
+ assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(XMPDC.CREATOR));
+ }
+
+ /** Language-alt items are exposed under key:lang, for both canonical and xmp-namespaced keys. */
+ @Test
+ public void testLanguageVariants() throws Exception {
+ String ml =
+ ""
+ + ""
+ + ""
+ + " Hello World"
+ + " Bonjour World"
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(ml.getBytes(UTF_8), md);
+ assertEquals("Hello World", md.get("dc:title:x-default"));
+ assertEquals("Bonjour World", md.get("dc:title:fr-ca"));
+ assertEquals("Bonjour World", md.get("xmp:dc:title:fr-ca")); // Option A: xmp-namespaced too
+ }
+
+ /** xmpMM:History is capped (MAX_HISTORY_EVENTS) so a hostile packet can't inflate metadata. */
+ @Test
+ public void testHistoryEventsAreCapped() throws Exception {
+ StringBuilder sb = new StringBuilder(
+ ""
+ + ""
+ + "");
+ for (int i = 0; i < 1100; i++) {
+ sb.append("saved");
+ }
+ sb.append("");
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(sb.toString().getBytes(UTF_8), md);
+ assertEquals(1024, md.getValues(XMPMM.HISTORY_ACTION).length); // capped, not 1100
+ }
+
+ /** rdf:about is populated to xmp:About when non-empty, and skipped when empty. */
+ @Test
+ public void testRdfAboutMapsToXmpAbout() throws Exception {
+ String withAbout =
+ ""
+ + ""
+ + ""
+ + "application/pdf"
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(withAbout.getBytes(UTF_8), md);
+ assertEquals("uuid:doc-42", md.get(XMP.ABOUT));
+
+ Metadata md2 = new Metadata();
+ new XmpExtractor().extract(withAbout.replace("uuid:doc-42", "").getBytes(UTF_8), md2);
+ assertNull(md2.get(XMP.ABOUT), "empty rdf:about must not be recorded");
+ }
+
+ /** Two rdf:RDF blocks in one packet are both processed. */
+ @Test
+ public void testMultipleRdfBlocks() throws Exception {
+ String packet =
+ ""
+ + ""
+ + ""
+ + "First Block"
+ + ""
+ + ""
+ + ""
+ + "Second Block"
+ + "";
+ Metadata md = new Metadata();
+ new XmpExtractor().extract(packet.getBytes(UTF_8), md);
+ assertEquals("First Block", md.get(TikaCoreProperties.TITLE));
+ assertEquals("Second Block", md.get(TikaCoreProperties.CREATOR));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java
new file mode 100644
index 00000000000..42eac27cab7
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.xmp;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class XmpSaxFlattenerTest {
+
+ private static final String PACKET =
+ ""
+ + ""
+ + ""
+ + " 2020-01-02T03:04:05Z"
+ + " AliceBob"
+ + " Hello"
+ + " "
+ + " docid-123"
+ + " "
+ + " "
+ + " created"
+ + " saved"
+ + " "
+ + "";
+
+ private List flatten() throws Exception {
+ return new XmpSaxFlattener().flatten(PACKET.getBytes(UTF_8));
+ }
+
+ private static String get(List l, String path) {
+ List v = new ArrayList<>();
+ for (XmpProperty p : l) {
+ if (p.path.equals(path)) {
+ v.add(p.value);
+ }
+ }
+ return v.isEmpty() ? null : String.join("|", v);
+ }
+
+ @Test
+ public void testSimpleAndCompact() throws Exception {
+ List l = flatten();
+ assertEquals("2020-01-02T03:04:05Z", get(l, "xmp:CreateDate"));
+ assertEquals("Canon", get(l, "tiff:Make")); // compact attribute form
+ }
+
+ @Test
+ public void testArrayIndices() throws Exception {
+ List l = flatten();
+ assertEquals("Alice", get(l, "dc:creator[1]"));
+ assertEquals("Bob", get(l, "dc:creator[2]"));
+ }
+
+ @Test
+ public void testLangAlt() throws Exception {
+ List l = flatten();
+ assertEquals("Hello", get(l, "dc:title[1]"));
+ assertEquals("x-default", get(l, "dc:title[1]/xml:lang"));
+ }
+
+ @Test
+ public void testStructAndHistory() throws Exception {
+ List l = flatten();
+ assertEquals("docid-123", get(l, "xmpMM:DerivedFrom/stRef:documentID"));
+ assertEquals("created", get(l, "xmpMM:History[1]/stEvt:action"));
+ assertEquals("saved", get(l, "xmpMM:History[2]/stEvt:action"));
+ }
+
+ @Test
+ public void testNamespaceUriTracked() throws Exception {
+ List l = flatten();
+ for (XmpProperty p : l) {
+ if (p.path.equals("xmp:CreateDate")) {
+ assertEquals("http://ns.adobe.com/xap/1.0/", p.namespaceURI);
+ }
+ if (p.path.startsWith("dc:creator")) {
+ assertEquals("http://purl.org/dc/elements/1.1/", p.namespaceURI);
+ }
+ }
+ }
+
+ @Test
+ public void testToolkitAndAboutDropped() throws Exception {
+ List l = flatten();
+ // x:xmptk (adobe:ns:meta/) and the empty rdf:about are not emitted as leaves
+ assertNull(get(l, "x:xmptk"));
+ for (XmpProperty p : l) {
+ assertTrue(!p.path.contains("xmptk") && !p.path.contains("about"),
+ "unexpected leaf: " + p);
+ }
+ }
+
+ /** Over-cap leaves are dropped so a hostile packet can't produce a giant key or value. */
+ @Test
+ public void testOverlongPathAndValueDropped() throws Exception {
+ String longName = "a".repeat(600); // path > MAX_PATH (512)
+ String bigValue = "v".repeat((1 << 20) + 64); // value > MAX_VALUE (1 MB)
+ String packet =
+ ""
+ + ""
+ + "x"
+ + "" + bigValue + ""
+ + "fine"
+ + "";
+ List leaves = new XmpSaxFlattener().flatten(packet.getBytes(UTF_8));
+ assertEquals("fine", get(leaves, "foo:ok")); // normal leaf survives
+ assertNull(get(leaves, "foo:blob")); // bloated value dropped
+ for (XmpProperty p : leaves) {
+ assertTrue(p.path.length() <= 512, "overlong path leaked");
+ }
+ }
+
+ /** The flattener caps its leaf list (MAX_LEAVES) so a hostile packet can't inflate metadata. */
+ @Test
+ public void testLeafListIsCapped() throws Exception {
+ StringBuilder sb = new StringBuilder(
+ ""
+ + "");
+ for (int i = 0; i < 60000; i++) {
+ sb.append("v");
+ }
+ sb.append("");
+ List leaves = new XmpSaxFlattener().flatten(sb.toString().getBytes(UTF_8));
+ assertEquals(50000, leaves.size()); // capped at MAX_LEAVES, not 60000
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java
deleted file mode 100644
index 6aff54ffdd1..00000000000
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java
+++ /dev/null
@@ -1,165 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.tika.parser.xmp;
-
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-import java.util.Collection;
-
-import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream;
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.TikaTest;
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.io.TikaInputStream;
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.metadata.XMPMM;
-
-/**
- *
- * @author Tilman Hausherr
- */
-public class XmpboxExtractorTest extends TikaTest {
-
- private final XMPPacketScanner scanner = new XMPPacketScanner();
-
- @Test // parsing fails because of bad date "2010-07-28T11:02:12.000CEST" = UTC+02:00
- public void testParseJpeg() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testJPEG_commented.jpg")) {
- UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get();
- boolean parsed = scanner.parse(tis, xmpraw);
- assertTrue(parsed);
-
- // set some values before extraction to see that they are overridden
-
- //TODO this doesn't work here because this extractor uses addMetadata() which works
- // differently than metadata.set(). We may want to fix one or the other.
-// metadata.set(TikaCoreProperties.TITLE, "old title");
-// metadata.set(TikaCoreProperties.DESCRIPTION, "old description");
-// metadata.set(TikaCoreProperties.CREATOR, "previous author");
- // ... or kept in case the field is multi-value
- metadata.add(TikaCoreProperties.SUBJECT, "oldkeyword");
-
- // xmpbox fails parsing on bad dates
- String s = xmpraw.toString(StandardCharsets.UTF_8);
- s = s.replace("CEST\"", "+02:00\"");
-
- XMPMetadataExtractor.parse(new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)), metadata);
-
- // DublinCore fields
- assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE));
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("oldkeyword"));
- assertTrue(keywords.contains("grazelands"));
- assertTrue(keywords.contains("nature reserve"));
- assertTrue(keywords.contains("bird watching"));
- assertTrue(keywords.contains("coast"));
- }
- }
-
- @Test
- public void testParseJpegPhotoshop() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream(
- "/test-documents/testJPEG_commented_pspcs2mac.jpg")) {
- UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get();
- boolean parsed = scanner.parse(tis, xmpraw);
- assertTrue(parsed);
-
- try (InputStream is = xmpraw.toInputStream()) {
- XMPMetadataExtractor.parse(is, metadata);
- }
-
- // DublinCore fields
- assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE));
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("bird watching"));
- assertTrue(keywords.contains("coast"));
- }
- }
-
- @Test
- public void testParseJpegXnviewmp() throws IOException, TikaException {
- Metadata metadata = new Metadata();
- try (TikaInputStream tis = getResourceAsStream(
- "/test-documents/testJPEG_commented_xnviewmp026.jpg")) {
- UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get();
- boolean parsed = scanner.parse(tis, xmpraw);
- assertTrue(parsed);
-
- try (InputStream is = xmpraw.toInputStream()) {
- XMPMetadataExtractor.parse(is, metadata);
- }
-
- assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)",
- metadata.get(TikaCoreProperties.DESCRIPTION));
- Collection keywords =
- Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT));
- assertTrue(keywords.contains("coast"));
- assertTrue(keywords.contains("nature reserve"));
- }
- }
-
- @Test
- public void testMaxXMPMMHistory() throws Exception {
- Metadata metadata = new Metadata();
- int maxHistory = XMPMetadataExtractor.getMaxXMPMMHistory();
- try {
- try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) {
- UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get();
- boolean parsed = scanner.parse(tis, xmpraw);
- assertTrue(parsed);
-
- try (InputStream is = xmpraw.toInputStream()) {
- XMPMetadataExtractor.parse(is, metadata);
- }
-
- assertEquals(7, metadata.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length);
-
- XMPMetadataExtractor.setMaxXMPMMHistory(5);
- metadata = new Metadata();
- try (InputStream is = xmpraw.toInputStream()) {
- XMPMetadataExtractor.parse(is, metadata);
- }
-
- assertEquals(5, metadata.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length);
- }
- }
- finally {
- //if something goes wrong, make sure to set this back to what it was
- XMPMetadataExtractor.setMaxXMPMMHistory(maxHistory);
- }
- }
-
-}