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"));
+    }
 }

Reply via email to