Skip to content

Commit 3a73a15

Browse files
committed
Expose all parsed XMP properties from images (#2927)
1 parent 83ba79b commit 3a73a15

4 files changed

Lines changed: 124 additions & 1 deletion

File tree

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@
2525
import java.util.Date;
2626
import java.util.Iterator;
2727
import java.util.Locale;
28+
import java.util.Map;
2829
import java.util.TimeZone;
2930
import java.util.regex.Matcher;
3031
import java.util.regex.Pattern;
3132

33+
import com.adobe.internal.xmp.XMPException;
34+
import com.adobe.internal.xmp.XMPMetaFactory;
3235
import com.drew.imaging.heif.HeifMetadataReader;
3336
import com.drew.imaging.jpeg.JpegMetadataReader;
3437
import com.drew.imaging.jpeg.JpegProcessingException;
@@ -52,6 +55,7 @@
5255
import com.drew.metadata.iptc.IptcDirectory;
5356
import com.drew.metadata.jpeg.JpegCommentDirectory;
5457
import com.drew.metadata.jpeg.JpegDirectory;
58+
import com.drew.metadata.xmp.XmpDirectory;
5559
import org.apache.commons.io.IOUtils;
5660
import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
5761
import org.apache.jempbox.xmp.XMPMetadata;
@@ -93,7 +97,7 @@ public class ImageMetadataExtractor {
9397
public ImageMetadataExtractor(Metadata metadata) {
9498
this(metadata, new CopyUnknownFieldsHandler(), new TiffPageNumberHandler(),
9599
new JpegCommentHandler(), new ExifHandler(), new DimensionsHandler(),
96-
new GeotagHandler(), new IptcHandler());
100+
new GeotagHandler(), new IptcHandler(), new XmpHandler());
97101
}
98102

99103
/**
@@ -304,6 +308,56 @@ public void handle(Directory directory, Metadata metadata) throws MetadataExcept
304308
}
305309
}
306310

311+
/**
312+
* Copies the XMP properties parsed by Metadata Extractor into the metadata,
313+
* keyed by their {@code prefix:name} path. The other handlers copy a
314+
* directory's tags, but XMP keeps its properties in a separate map
315+
* ({@link XmpDirectory#getXmpProperties()}), so without this they are lost.
316+
* A property is skipped when its key is already set or matches a known Tika
317+
* field, so normalized values from other handlers are not overwritten.
318+
*/
319+
static class XmpHandler implements DirectoryHandler {
320+
321+
static {
322+
// XMPCore's namespace registry is process-global and keeps the first
323+
// prefix it sees for a URI. Pin canonical prefixes so keys stay stable
324+
// (files use both Camera and GCamera for the Google photo namespace).
325+
// https://developer.android.com/media/platform/motion-photo-format
326+
try {
327+
XMPMetaFactory.getSchemaRegistry()
328+
.registerNamespace("http://ns.google.com/photos/1.0/camera/", "Camera");
329+
XMPMetaFactory.getSchemaRegistry()
330+
.registerNamespace("http://ns.google.com/photos/1.0/container/", "Container");
331+
XMPMetaFactory.getSchemaRegistry()
332+
.registerNamespace("http://ns.google.com/photos/1.0/container/item/", "Item");
333+
} catch (XMPException e) {
334+
// Constant, valid URIs, so this cannot throw. A broken registration
335+
// would make the keys non-deterministic (parse-order dependent), which
336+
// MotionPhotoXmpTest catches in CI; rethrowing from a static initializer
337+
// would break all image parsing, so it is swallowed.
338+
}
339+
}
340+
341+
public boolean supports(Class<? extends Directory> directoryType) {
342+
return XmpDirectory.class.isAssignableFrom(directoryType);
343+
}
344+
345+
public void handle(Directory directory, Metadata metadata) throws MetadataException {
346+
Map<String, String> properties = ((XmpDirectory) directory).getXmpProperties();
347+
if (properties == null) {
348+
return;
349+
}
350+
for (Map.Entry<String, String> property : properties.entrySet()) {
351+
String name = property.getKey();
352+
String value = property.getValue();
353+
if (value != null && metadata.get(name) == null
354+
&& !MetadataFields.isMetadataField(name)) {
355+
metadata.set(name, value);
356+
}
357+
}
358+
}
359+
}
360+
307361
static class TiffPageNumberHandler implements DirectoryHandler {
308362
public boolean supports(Class<? extends Directory> directoryType) {
309363
return true;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.image;
18+
19+
import java.io.InputStream;
20+
import static org.junit.jupiter.api.Assertions.assertEquals;
21+
import static org.junit.jupiter.api.Assertions.assertNull;
22+
23+
import org.junit.jupiter.api.Test;
24+
import org.xml.sax.helpers.DefaultHandler;
25+
26+
import org.apache.tika.TikaTest;
27+
import org.apache.tika.metadata.Metadata;
28+
import org.apache.tika.parser.ParseContext;
29+
30+
/**
31+
* Google Motion Photos keep their metadata in a vendor XMP namespace. Two
32+
* variants share that namespace: the current Motion Photo format and the legacy
33+
* MicroVideo format. Both are covered here.
34+
*/
35+
public class MotionPhotoXmpTest extends TikaTest {
36+
37+
/** XMP from a vendor namespace (Google Motion Photo) is exposed, not dropped. */
38+
@Test
39+
public void testMotionPhotoXmpIsExposed() throws Exception {
40+
Metadata metadata = new Metadata();
41+
metadata.set(Metadata.CONTENT_TYPE, "image/jpeg");
42+
try (InputStream tis =
43+
getResourceAsStream("/test-documents/testJPEG_MotionPhoto.jpg")) {
44+
new JpegParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
45+
}
46+
47+
assertEquals("1", metadata.get("Camera:MotionPhoto"));
48+
assertEquals("1", metadata.get("Camera:MotionPhotoVersion"));
49+
assertEquals("500000", metadata.get("Camera:MotionPhotoPresentationTimestampUs"));
50+
// The embedded video item (its byte length lets a client range-fetch the
51+
// video without downloading the whole file) is exposed too.
52+
assertEquals("MotionPhoto", metadata.get("Container:Directory[2]/Item:Semantic"));
53+
assertEquals("122562", metadata.get("Container:Directory[2]/Item:Length"));
54+
}
55+
56+
/** Keys use the canonical prefix even when the file declares another (GCamera). */
57+
@Test
58+
public void testCanonicalPrefixIsStable() throws Exception {
59+
Metadata metadata = new Metadata();
60+
metadata.set(Metadata.CONTENT_TYPE, "image/jpeg");
61+
try (InputStream tis =
62+
getResourceAsStream("/test-documents/testJPEG_MicroVideo.jpg")) {
63+
new JpegParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
64+
}
65+
assertEquals("1", metadata.get("Camera:MicroVideo"));
66+
assertEquals("4182318", metadata.get("Camera:MicroVideoOffset"));
67+
assertNull(metadata.get("GCamera:MicroVideoOffset"));
68+
}
69+
}
Loading
Loading

0 commit comments

Comments
 (0)