This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 1b6b7616e9 TIKA-4869: Emit the video of a motion photo as an embedded
document (#3115)
1b6b7616e9 is described below
commit 1b6b7616e92c7ffc43f3f5266b7c466cf2462956
Author: Dominik Schmidt <[email protected]>
AuthorDate: Wed Sep 2 22:48:13 2026 +0200
TIKA-4869: Emit the video of a motion photo as an embedded document (#3115)
---
CHANGES.txt | 8 +
.../image/MotionPhotoVideoIntegrationTest.java | 49 ++++
.../test-documents/testJPEG_MotionPhoto.jpg | Bin 0 -> 3040 bytes
.../tika/parser/image/AbstractImageParser.java | 3 +-
.../org/apache/tika/parser/image/MotionPhoto.java | 292 +++++++++++++++++++++
.../tika/parser/image/MotionPhotoVideoTest.java | 256 ++++++++++++++++++
.../tika/parser/image/MotionPhotoXmpTest.java | 4 +-
.../test-documents/testJPEG_MicroVideo.jpg | Bin 1082 -> 2662 bytes
.../test-documents/testJPEG_MotionPhoto.jpg | Bin 1459 -> 3040 bytes
...nPhoto.jpg => testJPEG_MotionPhoto_noVideo.jpg} | Bin 1459 -> 1211 bytes
.../apache/tika/parser/xmp/XmpSaxFlattener.java | 27 +-
.../tika/parser/xmp/XmpSaxFlattenerTest.java | 31 +++
12 files changed, 665 insertions(+), 5 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 80e898aa72..2684295d7c 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
Release 4.1.0 - unreleased
+ * The video of a Google/Android motion photo, appended after the image and
+ described by the Motion Photo or MicroVideo XMP, is emitted as an
+ ATTACHMENT embedded document named after what the file declares it to
+ be. Nothing is emitted, and nothing is recorded, when the declared
+ length does not fit the file or the bytes there are not recognized,
+ which is what sharing a motion photo out of a gallery leaves behind
+ (TIKA-4869).
+
* Raster previews for the vector thumbnails of Office documents: the new
poi-metafile-renderer draws EMF and WMF images through POI (a PNG of
a configurable width; Word's bitmap-in-WMF thumbnails from the bitmap
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoIntegrationTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoIntegrationTest.java
new file mode 100644
index 0000000000..db202d8707
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoIntegrationTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * With the full parser set the video of a motion photo is typed and parsed
+ * as the MP4 it is (TIKA-4869).
+ */
+public class MotionPhotoVideoIntegrationTest extends TikaTest {
+
+ @Test
+ public void testMotionPhotoVideoIsAnMp4() throws Exception {
+ List<Metadata> metadataList =
getRecursiveMetadata("testJPEG_MotionPhoto.jpg");
+ assertEquals(2, metadataList.size());
+ Metadata video = metadataList.get(1);
+ assertEquals("video/mp4", video.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("motion-photo.mp4",
video.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
+ video.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ //parsed like any other embedded document
+ assertEquals("32", video.get("tiff:ImageWidth"));
+ assertEquals("0.2", video.get("xmpDM:duration"));
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
new file mode 100644
index 0000000000..6f82e2eff7
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
index 954649b2b0..7cbbe440f8 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
@@ -78,6 +78,7 @@ public abstract class AbstractImageParser implements Parser {
extractMetadata(tis, handler, metadata, context);
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler,
metadata, context);
xhtml.startDocument();
+ MotionPhoto.extract(tis, metadata, xhtml, context);
xhtml.endDocument();
return;
}
@@ -98,7 +99,6 @@ public abstract class AbstractImageParser implements Parser {
} catch (Exception e) {
metadataException = e;
}
-
try (TikaInputStream pathStream = TikaInputStream.get(path)) {
//specify ocr content type
String originalParserOverride =
@@ -126,6 +126,7 @@ public abstract class AbstractImageParser implements Parser
{
}
}
}
+ MotionPhoto.extract(tis, metadata, xhtml, context);
xhtml.endDocument();
} finally {
tmpResources.close();
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/MotionPhoto.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/MotionPhoto.java
new file mode 100644
index 0000000000..bec1af9cdf
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/MotionPhoto.java
@@ -0,0 +1,292 @@
+/*
+ * 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.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.xml.sax.SAXException;
+
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Google;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.EmbeddedContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * The video of a motion photo, appended after the image and described by the
+ * XMP the image parsers already extract (TIKA-4869):
+ * <ul>
+ * <li>a Motion Photo lists its parts in {@code Container:Directory}: the
+ * primary image first, the video last with nothing after it, each with
+ * an {@code Item:Length};</li>
+ * <li>the older MicroVideo gives {@code Camera:MicroVideoOffset}, the
+ * number of bytes from the end of the file to the start of the
video.</li>
+ * </ul>
+ * Either way the video ends at the end of the file, so its start follows from
+ * its length. What is found there is typed by content: the declared
+ * {@code Item:Mime} is not used as a detection hint, because a hint would
+ * make a wrong length pass as a video, and nothing is emitted when detection
+ * recognizes nothing.
+ * <p>
+ * The same holds for HEIC motion photos, whose video sits in a trailing
+ * {@code mpvd} box; its 8 byte header is the primary item's padding, so the
+ * video still ends at the end of the file.
+ */
+final class MotionPhoto {
+
+ /**
+ * The name the video is emitted under, with the extension of whatever it
+ * turns out to be.
+ */
+ private static final String NAME = "motion-photo";
+
+ private static final String ITEM = "]/Container:Item/";
+ private static final String DIRECTORY = "xmp-raw:Container:Directory[";
+
+ /**
+ * The {@code Item:Semantic} of the video.
+ */
+ private static final String MOTION_PHOTO = "MotionPhoto";
+
+ /**
+ * A directory holds a handful of items; this only bounds the walk.
+ */
+ private static final int MAX_ITEMS = 64;
+
+ /**
+ * Enough of the video for the detectors to recognize it.
+ */
+ private static final int DETECTION_PREFIX = 8 * 1024;
+
+ /**
+ * The branch below an emitted trailer, which is not searched for a trailer
+ * of its own: what is appended to an image may be an image again, and a
+ * crafted file can nest that as deep as it likes.
+ */
+ private static final class Nested {
+ }
+
+ private static final Nested NESTED = new Nested();
+
+ private MotionPhoto() {
+ }
+
+ /**
+ * Emits the trailer as an embedded document, or nothing when the image
+ * declares none, when the declared length does not fit the file, or when
+ * the bytes there are not recognized. The last two are what sharing a
+ * motion photo out of a gallery leaves behind, a common enough thing that
+ * it is not worth an exception on a file that is otherwise fine; the XMP
+ * that promised the video is in the metadata for a client to see.
+ */
+ static void extract(TikaInputStream tis, Metadata metadata,
XHTMLContentHandler xhtml,
+ ParseContext context) throws IOException, SAXException
{
+ if (context.get(Nested.class) != null) {
+ return;
+ }
+ Declaration declared = declaration(metadata);
+ if (declared == null) {
+ return;
+ }
+ //a length the file cannot hold is settled from what the stream already
+ //knows, before an image gets spilled to disk on the strength of it
+ if (tis.hasLength() && declared.length >= tis.getLength()) {
+ return;
+ }
+ Trailer trailer = locate(tis, declared, context);
+ if (trailer == null) {
+ return;
+ }
+ Metadata trailerMetadata = Metadata.newInstance(context);
+ //the name has to be set before the parse, and the declaration names
the
+ //format the file was written with, which detection cannot always tell
+ //apart: an MP4 with the isom brand types as quicktime (TIKA-3646), and
+ //the MicroVideo format declares nothing at all. Where the two disagree
+ //about the kind of file it is, the bytes win.
+ boolean fromDetection = declared.mime != null
+ && !declared.mime.startsWith(trailer.type.getType() + "/");
+ String extension = declared.mime == null ? "" : EmbeddedDocumentUtil
+ .getExtensionForMediaType(
+ fromDetection ? trailer.type.toString() :
declared.mime);
+ trailerMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, NAME +
extension);
+ if (fromDetection && !extension.isEmpty()) {
+
trailerMetadata.set(TikaCoreProperties.RESOURCE_NAME_EXTENSION_INFERRED, true);
+ }
+ trailerMetadata.set(HttpHeaders.CONTENT_TYPE, trailer.type.toString());
+ //the declaration is exact: the trailer runs from there to the end of
+ //the file, and a client should not have to read it to learn its size
+ trailerMetadata.set(HttpHeaders.CONTENT_LENGTH,
Long.toString(declared.length));
+ trailerMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name());
+ EmbeddedDocumentExtractor extractor =
+ EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+ if (!extractor.shouldParseEmbedded(trailerMetadata, context)) {
+ return;
+ }
+ context.set(Nested.class, NESTED);
+ //an opener rather than a stream: the trailer is a region of a file, so
+ //the parse can go back to the start of it without a copy, and it knows
+ //its length from the metadata above rather than by spooling for it
+ try (TemporaryResources tmp = new TemporaryResources();
+ TikaInputStream embedded = TikaInputStream.get(
+ () -> region(trailer.file, trailer.start), tmp,
trailerMetadata)) {
+ extractor.parseEmbedded(embedded, new
EmbeddedContentHandler(xhtml), trailerMetadata,
+ context, true);
+ } finally {
+ context.set(Nested.class, null);
+ }
+ }
+
+ /**
+ * What the declaration points at: where the bytes start and what they turn
+ * out to be, or null when they are not there, are not recognized, or
+ * cannot be read. An image that parsed is not failed over a trailer that
+ * is out of reach.
+ */
+ private static Trailer locate(TikaInputStream tis, Declaration declared,
+ ParseContext context) {
+ try {
+ Path file = tis.getPath();
+ long start = Files.size(file) - declared.length;
+ if (start <= 0) {
+ return null;
+ }
+ MediaType type = detect(file, start, context);
+ if (type == null || MediaType.OCTET_STREAM.equals(type)) {
+ return null;
+ }
+ return new Trailer(file, start, type);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The bytes behind the image: where they start and what they are.
+ */
+ private static final class Trailer {
+ final Path file;
+ final long start;
+ final MediaType type;
+
+ Trailer(Path file, long start, MediaType type) {
+ this.file = file;
+ this.start = start;
+ this.type = type;
+ }
+ }
+
+ /**
+ * What the image says about its video: how many bytes it occupies at the
+ * end of the file and, where the format has it, the type it claims.
+ */
+ static Declaration declaration(Metadata metadata) {
+ Declaration fromDirectory = directoryDeclaration(metadata);
+ if (fromDirectory != null) {
+ return fromDirectory;
+ }
+ long microVideo =
positiveLong(metadata.get(Google.MICRO_VIDEO_OFFSET));
+ //the MicroVideo format names no type
+ return microVideo > 0 ? new Declaration(microVideo, null) : null;
+ }
+
+ /**
+ * A declared video: its length to the end of the file and its claimed
+ * type, which may be null.
+ */
+ static final class Declaration {
+ final long length;
+ final String mime;
+
+ Declaration(long length, String mime) {
+ this.length = length;
+ this.mime = mime;
+ }
+ }
+
+ /**
+ * The video item's own length, which is what separates it from the end of
+ * the file: the format has it last and lets nothing follow it, in Ultra
HDR
+ * files as well, where the gain map comes before it.
+ */
+ private static Declaration directoryDeclaration(Metadata metadata) {
+ for (int i = 1; i <= MAX_ITEMS; i++) {
+ String item = DIRECTORY + i + ITEM;
+ String semantic = metadata.get(item + "Item:Semantic");
+ if (semantic == null) {
+ return null;
+ }
+ if (MOTION_PHOTO.equals(semantic)) {
+ long length = positiveLong(metadata.get(item + "Item:Length"));
+ return length > 0
+ ? new Declaration(length, metadata.get(item +
"Item:Mime"))
+ : null;
+ }
+ }
+ return null;
+ }
+
+ private static long positiveLong(String value) {
+ if (value == null) {
+ return -1;
+ }
+ try {
+ long parsed = Long.parseLong(value.trim());
+ return parsed > 0 ? parsed : -1;
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Types the data at the offset by content alone.
+ */
+ private static MediaType detect(Path file, long start, ParseContext
context)
+ throws IOException {
+ byte[] prefix;
+ try (InputStream is = region(file, start)) {
+ prefix = is.readNBytes(DETECTION_PREFIX);
+ }
+ try (TikaInputStream tis = TikaInputStream.get(prefix)) {
+ return EmbeddedDocumentUtil.getDetector(context).detect(tis, new
Metadata(), context);
+ }
+ }
+
+ /**
+ * The file from the offset to its end.
+ */
+ private static InputStream region(Path file, long start) throws
IOException {
+ InputStream is = Files.newInputStream(file);
+ try {
+ is.skipNBytes(start);
+ } catch (IOException e) {
+ is.close();
+ throw e;
+ }
+ return is;
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoTest.java
new file mode 100644
index 0000000000..9ee809a964
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoVideoTest.java
@@ -0,0 +1,256 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * The video a motion photo carries after the image becomes an embedded
+ * document (TIKA-4869).
+ */
+public class MotionPhotoVideoTest extends TikaTest {
+
+ /**
+ * A Motion Photo describes its video in Container:Directory.
+ */
+ @Test
+ public void testMotionPhotoVideo() throws Exception {
+ List<Metadata> metadataList =
getRecursiveMetadata("testJPEG_MotionPhoto.jpg");
+ assertEquals(2, metadataList.size());
+ Metadata video = metadataList.get(1);
+ //this module's classpath types the video by mime magic alone, which
+ //gets it as far as quicktime; the integration test pins video/mp4
+ assertTrue(video.get(HttpHeaders.CONTENT_TYPE).startsWith("video/"),
+ video.get(HttpHeaders.CONTENT_TYPE));
+ //the Motion Photo format declares the type of its video
+ assertEquals("motion-photo.mp4",
video.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
+ video.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ //the length is known from the declaration, as it is for a zip entry
+ assertEquals(String.valueOf(declaredLength()),
video.get(HttpHeaders.CONTENT_LENGTH));
+
assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ }
+
+ /**
+ * The older MicroVideo gives the length as an offset from the end.
+ */
+ @Test
+ public void testMicroVideo() throws Exception {
+ List<Metadata> metadataList =
getRecursiveMetadata("testJPEG_MicroVideo.jpg");
+ assertEquals(2, metadataList.size());
+ Metadata video = metadataList.get(1);
+ assertTrue(video.get(HttpHeaders.CONTENT_TYPE).startsWith("video/"),
+ video.get(HttpHeaders.CONTENT_TYPE));
+ //the MicroVideo format declares no type, so the name carries no
extension
+ assertEquals("motion-photo",
video.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
+ video.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ /**
+ * A JPEG without the XMP of a motion photo keeps to itself.
+ */
+ @Test
+ public void testPlainJpegHasNoVideo() throws Exception {
+ assertEquals(1, getRecursiveMetadata("testJPEG.jpg").size());
+ }
+
+ /**
+ * Sharing a motion photo out of the Android gallery leaves the image with
+ * the Camera:MotionPhoto flag but without the video and without the
+ * directory that would locate it: nothing to emit, and nothing to
+ * complain about.
+ */
+ @Test
+ public void testMotionPhotoFlagWithoutAVideo() throws Exception {
+ List<Metadata> metadataList =
getRecursiveMetadata("testJPEG_MotionPhoto_noVideo.jpg");
+ assertEquals(1, metadataList.size());
+ assertEquals("1", metadataList.get(0).get("Camera:MotionPhoto"));
+
assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ }
+
+ /**
+ * The length fits the file, but what is there is not a video: no embedded
+ * document, and no complaint either.
+ */
+ @Test
+ public void testDeclaredVideoIsNotOne(@TempDir Path tmp) throws Exception {
+ byte[] file = fixture();
+ //keep the length, replace the video with something unrecognizable
+ java.util.Arrays.fill(file, file.length - declaredLength(),
file.length, (byte) 0);
+ Path overwritten = tmp.resolve("no-video.jpg");
+ Files.write(overwritten, file);
+
+ List<Metadata> metadataList = parse(overwritten);
+ assertEquals(1, metadataList.size());
+
assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ }
+
+ /**
+ * A declared length the file cannot hold, which is what stripping the
+ * video out of a motion photo leaves: no embedded document, and no
+ * complaint about a file that is otherwise fine.
+ */
+ @Test
+ public void testDeclaredLengthBeyondTheFile(@TempDir Path tmp) throws
Exception {
+ byte[] file = fixture();
+ //cut the video the XMP still declares
+ Path truncated = tmp.resolve("truncated.jpg");
+ Files.write(truncated, java.util.Arrays.copyOf(file, file.length -
declaredLength()));
+
+ List<Metadata> metadataList = parse(truncated);
+ assertEquals(1, metadataList.size());
+
assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+ }
+
+ /**
+ * The video is the last thing in the file, so its own Item:Length is the
+ * whole distance to the end: an item behind it is not added on, and
neither
+ * is a Padding, which the format only allows on the primary image. Lengths
+ * are read from the file, so summing them could be made to overflow.
+ */
+ @Test
+ public void testTheDeclarationIsTheVideosOwnLength() {
+ Metadata metadata = new Metadata();
+ item(metadata, 1, "Primary", "3000", null);
+ item(metadata, 2, "MotionPhoto", "1583",
String.valueOf(Long.MAX_VALUE));
+ //a shared resource is declared with a length of 0, and may follow the
video
+ item(metadata, 3, "Segment", "0", null);
+ assertEquals(1583, MotionPhoto.declaration(metadata).length);
+ }
+
+ /**
+ * A length no file could hold ends it, rather than an offset that wraps
+ * around into the file and reads somewhere else.
+ */
+ @Test
+ public void testAbsurdDeclaredLength() throws Exception {
+ Metadata metadata = new Metadata();
+ item(metadata, 1, "Primary", "3000", null);
+ item(metadata, 2, "MotionPhoto", String.valueOf(Long.MAX_VALUE), null);
+ assertEquals(Long.MAX_VALUE, MotionPhoto.declaration(metadata).length);
+
+ try (TikaInputStream tis = TikaInputStream.get(
+
getResourceAsStream("/test-documents/testJPEG_MotionPhoto.jpg"))) {
+ XHTMLContentHandler xhtml =
+ new XHTMLContentHandler(new DefaultHandler(), metadata,
new ParseContext());
+ MotionPhoto.extract(tis, metadata, xhtml, new ParseContext());
+ }
+ }
+
+ private static void item(Metadata metadata, int index, String semantic,
String length,
+ String padding) {
+ String item = "xmp-raw:Container:Directory[" + index +
"]/Container:Item/";
+ metadata.set(item + "Item:Semantic", semantic);
+ metadata.set(item + "Item:Length", length);
+ if (padding != null) {
+ metadata.set(item + "Item:Padding", padding);
+ }
+ }
+
+ /**
+ * What is appended is typed by content, and what is there is emitted even
+ * where it is not the video the XMP promised. The name follows the bytes
+ * then, not the declaration.
+ */
+ @Test
+ public void testTrailerThatIsNotAVideo(@TempDir Path tmp) throws Exception
{
+ byte[] file = fixture();
+ byte[] pdf = new byte[declaredLength()];
+ java.util.Arrays.fill(pdf, (byte) ' ');
+ System.arraycopy("%PDF-1.4\n".getBytes(StandardCharsets.US_ASCII), 0,
pdf, 0, 9);
+ System.arraycopy(pdf, 0, file, file.length - pdf.length, pdf.length);
+ Path swapped = tmp.resolve("pdf-trailer.jpg");
+ Files.write(swapped, file);
+
+ List<Metadata> metadataList = parse(swapped);
+ assertEquals(2, metadataList.size());
+ Metadata trailer = metadataList.get(1);
+ assertEquals("application/pdf", trailer.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("motion-photo.pdf",
trailer.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals("true",
+
trailer.get(TikaCoreProperties.RESOURCE_NAME_EXTENSION_INFERRED));
+ }
+
+ /**
+ * An image appended to an image is emitted, but is not searched for a
+ * trailer of its own: a file that nests itself would otherwise chain as
+ * deep as it cares to.
+ */
+ @Test
+ public void testANestedMotionPhotoDoesNotChain(@TempDir Path tmp) throws
Exception {
+ byte[] inner = fixture();
+ //the fixture is as long as the length it declares has digits, so the
+ //outer file can declare the whole inner one without moving anything
+ byte[] outer = new String(fixture(), StandardCharsets.ISO_8859_1)
+ .replace("Item:Length=\"" + declaredLength() + "\"",
+ "Item:Length=\"" + inner.length + "\"")
+ .getBytes(StandardCharsets.ISO_8859_1);
+ Path nested = tmp.resolve("nested.jpg");
+ try (OutputStream out = Files.newOutputStream(nested)) {
+ out.write(outer);
+ out.write(inner);
+ }
+
+ List<Metadata> metadataList = parse(nested);
+ assertEquals(2, metadataList.size());
+ assertEquals("image/jpeg",
metadataList.get(1).get(HttpHeaders.CONTENT_TYPE));
+ }
+
+ private byte[] fixture() throws Exception {
+ try (InputStream is =
getResourceAsStream("/test-documents/testJPEG_MotionPhoto.jpg")) {
+ return is.readAllBytes();
+ }
+ }
+
+ /**
+ * The length the fixture's XMP declares, rather than that number spelled
+ * out in every test that needs it.
+ */
+ private int declaredLength() throws Exception {
+ return (int) MotionPhoto.declaration(
+
getRecursiveMetadata("testJPEG_MotionPhoto.jpg").get(0)).length;
+ }
+
+ private List<Metadata> parse(Path file) throws Exception {
+ try (TikaInputStream tis = TikaInputStream.get(file)) {
+ return getRecursiveMetadata(tis, AUTO_DETECT_PARSER, new
Metadata(),
+ new ParseContext(), false);
+ }
+ }
+}
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 712a5298ba..2ba8281513 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
@@ -55,7 +55,7 @@ public class MotionPhotoXmpTest extends TikaTest {
// video without downloading the whole file) is exposed too.
assertEquals("MotionPhoto",
metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic"));
- assertEquals("122562",
+ assertEquals("1583",
metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Length"));
}
@@ -69,7 +69,7 @@ public class MotionPhotoXmpTest extends TikaTest {
new JpegParser().parse(tis, new DefaultHandler(), metadata, new
ParseContext());
}
assertEquals("1", metadata.get("Camera:MicroVideo"));
- assertEquals("4182318", metadata.get("Camera:MicroVideoOffset"));
+ assertEquals("1583", 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
index 95be7411d6..78af81817c 100644
Binary files
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MicroVideo.jpg
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
index 2dd45f17e7..6f82e2eff7 100644
Binary files
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.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_noVideo.jpg
similarity index 69%
copy from
tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
copy to
tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto_noVideo.jpg
index 2dd45f17e7..d44c4d0610 100644
Binary files
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto.jpg
and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testJPEG_MotionPhoto_noVideo.jpg
differ
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
index f0e1506b23..6ba9d6948c 100644
---
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
@@ -23,6 +23,7 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import org.apache.commons.io.input.CloseShieldInputStream;
import org.xml.sax.Attributes;
@@ -30,6 +31,7 @@ import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.Google;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.utils.XMLReaderUtils;
@@ -77,6 +79,26 @@ public final class XmpSaxFlattener {
return RDF.equals(u) && (l.equals("Bag") || l.equals("Seq") ||
l.equals("Alt"));
}
+ /**
+ * The prefix a namespace is normally written with, for the few whose
+ * raw keys are read elsewhere in Tika. An XMP prefix is the writer's
+ * own choice (ISO 16684-1) and rewriting a packet is enough to change
+ * it, so a key built from the document's prefix moves with it: a
+ * motion photo that has been through exiftool lists its parts under
+ * GContainer:Directory rather than Container:Directory.
+ */
+ static final Map<String, String> CANONICAL_PREFIX =
+ Map.of(Google.CONTAINER_NS, "Container", Google.ITEM_NS,
"Item");
+
+ /**
+ * The name a path segment is keyed under: the document's own qName,
+ * except for the namespaces above.
+ */
+ static String canonical(String uri, String qName, String localName) {
+ String prefix = CANONICAL_PREFIX.get(uri);
+ return prefix == null ? qName : prefix + ":" + localName;
+ }
+
void add(XmpProperty p) {
if (out.size() >= MAX_LEAVES || p.path.length() > MAX_PATH
|| (p.value != null && p.value.length() > MAX_VALUE)) {
@@ -142,7 +164,7 @@ public final class XmpSaxFlattener {
}
return;
} else if (!rdf) {
- pushFrame(qn, u);
+ pushFrame(canonical(u, qn, l), u);
} else {
return;
}
@@ -185,7 +207,8 @@ public final class XmpSaxFlattener {
if (au == null || au.isEmpty()) {
continue;
}
- add(new XmpProperty(au, base.isEmpty() ? aq : base + "/" + aq,
v));
+ String an = canonical(au, aq, al);
+ add(new XmpProperty(au, base.isEmpty() ? an : base + "/" + an,
v));
}
}
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
index 42eac27cab..0462b2db3e 100644
---
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
@@ -22,7 +22,9 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -152,4 +154,33 @@ public class XmpSaxFlattenerTest {
List<XmpProperty> leaves = new
XmpSaxFlattener().flatten(sb.toString().getBytes(UTF_8));
assertEquals(50000, leaves.size()); // capped at MAX_LEAVES, not
60000
}
+
+ /**
+ * An XMP prefix is the writer's own choice: exiftool rewrites the Google
+ * container namespaces to GContainer, where the camera writes Container.
+ * The keys stay put, so what reads them does not have to know which tool
+ * last touched the file.
+ */
+ @Test
+ public void testGoogleContainerKeepsItsCanonicalPrefix() throws Exception {
+ String packet = "<x:xmpmeta xmlns:x='adobe:ns:meta/'>"
+ + "<rdf:RDF
xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>"
+ + "<rdf:Description rdf:about=''"
+ + "
xmlns:GContainer='http://ns.google.com/photos/1.0/container/'"
+ + "
xmlns:GItem='http://ns.google.com/photos/1.0/container/item/'>"
+ + "<GContainer:Directory><rdf:Seq>"
+ + "<rdf:li rdf:parseType='Resource'>"
+ + "<GContainer:Item GItem:Semantic='MotionPhoto'
GItem:Length='1583'/>"
+ + "</rdf:li>"
+ + "</rdf:Seq></GContainer:Directory>"
+ + "</rdf:Description></rdf:RDF></x:xmpmeta>";
+ Map<String, String> paths = new HashMap<>();
+ for (XmpProperty p : new XmpSaxFlattener().flatten(
+ packet.getBytes(UTF_8))) {
+ paths.put(p.path, p.value);
+ }
+ assertEquals("MotionPhoto",
+
paths.get("Container:Directory[1]/Container:Item/Item:Semantic"));
+ assertEquals("1583",
paths.get("Container:Directory[1]/Container:Item/Item:Length"));
+ }
}