TikaUserDataBox.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.mp4.boxes;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import com.drew.lang.SequentialByteArrayReader;
import com.drew.lang.SequentialReader;
import com.drew.lang.annotations.NotNull;
import com.drew.lang.annotations.Nullable;
import com.drew.metadata.mp4.Mp4Directory;
import org.xml.sax.SAXException;

import org.apache.tika.exception.RuntimeSAXException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Audio;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMP;
import org.apache.tika.metadata.XMPDM;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.sax.XHTMLContentHandler;

public class TikaUserDataBox {

    private static final String LOCATION_CODE = "\u00A9xyz";
    private static final String META = "meta";
    private static final String ILST = "ilst";
    private static final String MDTA = "mdta";
    private static final String HDLR = "hdlr";
    private static final String MDIR = "mdir";//apple metadata itunes reader

    @Nullable
    private String coordinateString;

    private boolean isQuickTime = false;
    private final Metadata metadata;
    private final XHTMLContentHandler xhtml;
    private final ParseContext parseContext;
    public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata metadata,
                           XHTMLContentHandler xhtml, ParseContext parseContext)
            throws IOException, SAXException {
        this.metadata = metadata;
        this.xhtml = xhtml;
        this.parseContext = parseContext;
        int length = payload.length;
        SequentialReader reader = new SequentialByteArrayReader(payload);
        while (reader.getPosition() < (long) length) {
            long size = reader.getUInt32();
            if (size <= 4L) {
                break;
            }
            String kindName = reader.getString(4, StandardCharsets.ISO_8859_1);
            if (LOCATION_CODE.equals(kindName)) {
                int xyzLength = reader.getUInt16();
                reader.skip(2L);
                this.coordinateString = reader.getString(xyzLength, "UTF-8");
            } else if (META.equals(kindName)) {
                reader.getUInt32();//not sure what this is
                long lengthToStartOfList = reader.getUInt32() - 4;//this is the length to
                // 'ilst', but the length of the ilist is defined in the 4 bytes before ilist
                if (lengthToStartOfList < 0 || lengthToStartOfList > Integer.MAX_VALUE) {
                    return;
                }
                String hdlr = reader.getString(4, StandardCharsets.ISO_8859_1);
                reader.getUInt32();
                reader.getUInt32();
                String subtype = reader.getString(4, StandardCharsets.ISO_8859_1);
                // If the second and the fifth 32-bit integers encode 'hdlr' and 'mdta' respectively
                // then the MetaBox is formatted according to QuickTime File Format.
                // See https://developer.apple.com/library/content/documentation
                // /QuickTime/QTFF/Metadata/Metadata.html
                if (HDLR.equals(hdlr) && MDTA.equals(subtype)) {
                    isQuickTime = true;
                }
                int read = 16;//bytes read so far
                parseUserDataBox(reader, subtype, read, (int)lengthToStartOfList);
            } else {
                if (size < 8L) {
                    return;
                }

                reader.skip(size - 8L);
            }
        }

    }

    private void parseUserDataBox(SequentialReader reader, String handlerType,
                                  int read, int lengthToStartOfList)
            throws IOException {
        if (!MDIR.equals(handlerType)) {
            return;
        }
        if (lengthToStartOfList < read) {
            return;
        }
        int toSkip = lengthToStartOfList - read;
        reader.skip(toSkip);
        long len = reader.getUInt32();
        String subType = reader.getString(4, StandardCharsets.ISO_8859_1);
        //walk the "free"-style sub-boxes to the ilst, validating each declared length
        //once before it is used: a length below the 8-byte header would make skip(len - 8)
        //negative (an IllegalArgumentException that escapes MP4Reader), and an oversize one
        //would desync the walk. Raise a caught IOException instead, so the udta walk aborts
        //and the problem is recorded as a parse error rather than silently mis-read. It
        //also throws (EOFException) if no ilst is found. See TIKA-4812.
        while (! ILST.equals(subType)) {
            if (len < 8L || len >= Integer.MAX_VALUE) {
                throw new IOException("Malformed box length in udta metadata: " + len);
            }
            reader.skip(len - 8);
            len = reader.getUInt32();
            subType = reader.getString(4, StandardCharsets.ISO_8859_1);
        }
        if (len < 8L || len >= Integer.MAX_VALUE) {
            throw new IOException("Malformed ilst length in udta metadata: " + len);
        }
        processIList(reader, len);
    }



    private void processIList(SequentialReader reader, long totalLen)
            throws IOException {

        long totalRead = 0;
        while (totalRead < totalLen) {
            long recordStart = reader.getPosition();
            long recordLen = reader.getUInt32();
            if (recordLen < 16) {
                //malformed record header; stop rather than loop or skip a negative span
                return;
            }
            String fieldName = reader.getString(4, StandardCharsets.ISO_8859_1);
            long fieldLen = reader.getUInt32();
            String typeName = reader.getString(4, StandardCharsets.ISO_8859_1);//data
            long recordEnd = recordStart + recordLen;
            if ("data".equals(typeName)) {
                //1 byte version and 3 bytes flags; for the "well-known" types the
                //flags hold the value type
                long valueType = reader.getUInt32() & 0xFFFFFF;
                reader.skip(4L);//locale
                int toRead = (int) fieldLen - 16;
                if (toRead > 0) {
                    if ("covr".equals(fieldName)) {
                        //covr holds one image per data atom and may repeat the data atom
                        //for further images; the realign below consumes any leftover
                        if (reader.getPosition() + toRead <= recordEnd) {
                            handleCoverArt(reader, valueType, toRead);
                        }
                        while (reader.getPosition() + 16 <= recordEnd) {
                            long extraLen = reader.getUInt32();
                            String extraType =
                                    reader.getString(4, StandardCharsets.ISO_8859_1);
                            long extraValueType = reader.getUInt32() & 0xFFFFFF;
                            reader.skip(4L);//locale
                            int extraToRead = (int) extraLen - 16;
                            if (!"data".equals(extraType) || extraToRead <= 0
                                    || reader.getPosition() + extraToRead > recordEnd) {
                                break;
                            }
                            handleCoverArt(reader, extraValueType, extraToRead);
                        }
                    } else if ("cpil".equals(fieldName)) {
                        metadata.set(XMPDM.COMPILATION, (int) reader.getByte());
                    } else if ("trkn".equals(fieldName)) {
                        if (toRead >= 8) {
                            long numA = reader.getUInt32();
                            long numB = reader.getUInt32();
                            metadata.set(XMPDM.TRACK_NUMBER, (int) numA);
                            //2 bytes track total, 2 bytes reserved
                            int trackCount = (int) (numB >>> 16);
                            if (trackCount > 0) {
                                metadata.set(Audio.TRACK_COUNT, trackCount);
                            }
                        }
                    } else if ("disk".equals(fieldName)) {
                        if (toRead >= 6) {
                            //2 bytes reserved, 2 bytes disc, 2 bytes total
                            int a = reader.getInt32();
                            short b = reader.getInt16();
                            metadata.set(XMPDM.DISC_NUMBER, a);
                            if (b > 0) {
                                metadata.set(Audio.DISC_COUNT, b);
                            }
                        }
                    } else if (reader.getPosition() + toRead <= recordEnd) {
                        String val = reader.getString(toRead, StandardCharsets.UTF_8);
                        try {
                            addMetadata(fieldName, val);
                        } catch (SAXException e) {
                            //need to punch through IOException catching in MP4Reader
                            throw new RuntimeSAXException(e);
                        }
                    }
                }
            }
            //realign to the end of the record regardless of what the branch consumed, so a
            //trailing sub-atom (e.g. a 'name' atom after 'data') can't desync the walk
            long pos = reader.getPosition();
            if (pos > recordEnd) {
                //a branch read past the record end (malformed lengths); stop
                return;
            }
            reader.skip(recordEnd - pos);
            totalRead += recordLen;
        }
    }


    /**
     * Sends one embedded cover image to the embedded document extractor.
     * The image only becomes an embedded document, no metadata is recorded
     * on the audio document itself.
     */
    private void handleCoverArt(SequentialReader reader, long valueType, int length)
            throws IOException {
        byte[] picture = reader.getBytes(length);
        Metadata pictureMetadata = Metadata.newInstance(parseContext);
        pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
        //the data atom's well-known value type declares the image format;
        //for any other type leave the content type for auto-detection
        if (valueType == 13) {
            pictureMetadata.set(HttpHeaders.CONTENT_TYPE, "image/jpeg");
        } else if (valueType == 14) {
            pictureMetadata.set(HttpHeaders.CONTENT_TYPE, "image/png");
        } else if (valueType == 27) {
            pictureMetadata.set(HttpHeaders.CONTENT_TYPE, "image/bmp");
        }
        EmbeddedDocumentExtractor extractor =
                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(parseContext);
        if (extractor.shouldParseEmbedded(pictureMetadata, parseContext)) {
            try (TikaInputStream tis = TikaInputStream.get(picture)) {
                extractor.parseEmbedded(tis, xhtml, pictureMetadata, parseContext, true);
            } catch (SAXException e) {
                //need to punch through IOException catching in MP4Reader
                throw new RuntimeSAXException(e);
            }
        }
    }

    private void addMetadata(String key, String value) throws SAXException {
        switch (key) {
            case "\u00A9nam":
                metadata.set(TikaCoreProperties.TITLE, value);
                xhtml.element("p", value);
                break;
            case "\u00A9too":
                metadata.set(XMP.CREATOR_TOOL, value);
                break;
            case "\u00A9ART" :
                metadata.set(XMPDM.ARTIST, value);
                metadata.set(TikaCoreProperties.CREATOR, value);
                xhtml.element("p", value);
                break;
            case "aART" :
                metadata.set(XMPDM.ALBUM_ARTIST, value);
                xhtml.element("p", value);
                break;
            case "\u00A9wrt":
                metadata.set(XMPDM.COMPOSER, value);
                xhtml.element("p", value);
                break;
            case "\u00A9alb":
                metadata.set(XMPDM.ALBUM, value);
                xhtml.element("p", value);
                break;
            case "\u00A9gen" :
                metadata.set(XMPDM.GENRE, value);
                xhtml.element("p", value);
                break;
            case "\u00A9day" :
                //this can be a year "2008" or a date "2017-04-26T07:00:00Z"
                metadata.set(XMPDM.RELEASE_DATE, value);
                xhtml.element("p", value);
                break;
            case "\u00A9cmt" :
                metadata.set(XMPDM.LOG_COMMENT, value);
                xhtml.element("p", value);
                break;
            case "cprt" :
                metadata.set(XMPDM.COPYRIGHT, value);
                xhtml.element("p", value);
                break;
            case "keyw" :
                metadata.set(TikaCoreProperties.SUBJECT, value);
                xhtml.element("p", value);
                break;
            case "\u00A9lyr" :
                xhtml.element("p", value);
                break;
            case "ldes" : //intentional fall through
            case "desc" :
                metadata.set(TikaCoreProperties.DESCRIPTION, value);
                xhtml.element("p", value);
                break;
            case "xid " :
                //not sure this is the right use of this key
                metadata.set(XMP.IDENTIFIER, value);
                break;
                //purd date?
                //xid ? e.g. SonyBMG:isrc:KRA031208874
                //cprt copyright
                //ownr ? and apID
                //flvr ?
                //son = nam, soal = (c)alb soar = aART?
                //(C)ART
        }
    }

    public void addMetadata(Mp4Directory directory) {
        if (this.coordinateString != null) {
            ISO6709.Location location = ISO6709.parse(this.coordinateString);
            if (location != null) {
                directory.setDouble(8193, location.latitude);
                directory.setDouble(8194, location.longitude);
                //Mp4Directory has no altitude tag, so set geo:alt directly
                if (location.altitude != null) {
                    metadata.set(TikaCoreProperties.ALTITUDE, location.altitude);
                }
            }
        }
    }
}