MotionPhoto.java

/*
 * 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;
    }
}