This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch branch_3x in repository https://gitbox.apache.org/repos/asf/tika.git
commit 3a73a1573ef6f7f4d61a9b68cca399ef192776d8 Author: Tilman Hausherr <[email protected]> AuthorDate: Wed Jul 8 02:27:53 2026 +0200 Expose all parsed XMP properties from images (#2927) --- .../tika/parser/image/ImageMetadataExtractor.java | 56 ++++++++++++++++- .../tika/parser/image/MotionPhotoXmpTest.java | 69 +++++++++++++++++++++ .../test-documents/testJPEG_MicroVideo.jpg | Bin 0 -> 1082 bytes .../test-documents/testJPEG_MotionPhoto.jpg | Bin 0 -> 1459 bytes 4 files changed, 124 insertions(+), 1 deletion(-) 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 cabf2345b9..90e0215751 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 @@ -25,10 +25,13 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Iterator; 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; @@ -52,6 +55,7 @@ import com.drew.metadata.icc.IccDirectory; import com.drew.metadata.iptc.IptcDirectory; import com.drew.metadata.jpeg.JpegCommentDirectory; import com.drew.metadata.jpeg.JpegDirectory; +import com.drew.metadata.xmp.XmpDirectory; import org.apache.commons.io.IOUtils; import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream; import org.apache.jempbox.xmp.XMPMetadata; @@ -93,7 +97,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 GeotagHandler(), new IptcHandler(), new XmpHandler()); } /** @@ -304,6 +308,56 @@ public class ImageMetadataExtractor { } } + /** + * 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<String, String> properties = ((XmpDirectory) directory).getXmpProperties(); + if (properties == null) { + return; + } + for (Map.Entry<String, String> 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) { return true; 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 new file mode 100644 index 0000000000..04bd36a80c --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java @@ -0,0 +1,69 @@ +/* + * 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.InputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import org.xml.sax.helpers.DefaultHandler; + +import org.apache.tika.TikaTest; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; + +/** + * Google Motion Photos keep their metadata in a vendor XMP namespace. Two + * variants share that namespace: the current Motion Photo format and the legacy + * MicroVideo format. Both are covered here. + */ +public class MotionPhotoXmpTest extends TikaTest { + + /** XMP from a vendor namespace (Google Motion Photo) is exposed, not dropped. */ + @Test + public void testMotionPhotoXmpIsExposed() throws Exception { + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/jpeg"); + try (InputStream tis = + getResourceAsStream("/test-documents/testJPEG_MotionPhoto.jpg")) { + new JpegParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + + assertEquals("1", metadata.get("Camera:MotionPhoto")); + assertEquals("1", metadata.get("Camera:MotionPhotoVersion")); + assertEquals("500000", metadata.get("Camera:MotionPhotoPresentationTimestampUs")); + // 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")); + } + + /** Keys use the canonical prefix even when the file declares another (GCamera). */ + @Test + public void testCanonicalPrefixIsStable() throws Exception { + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/jpeg"); + try (InputStream tis = + getResourceAsStream("/test-documents/testJPEG_MicroVideo.jpg")) { + new JpegParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + assertEquals("1", metadata.get("Camera:MicroVideo")); + assertEquals("4182318", metadata.get("Camera:MicroVideoOffset")); + assertNull(metadata.get("GCamera:MicroVideoOffset")); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MicroVideo.jpg b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MicroVideo.jpg new file mode 100644 index 0000000000..95be7411d6 Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MicroVideo.jpg differ diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg new file mode 100644 index 0000000000..2dd45f17e7 Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg differ
