HeicXmpTest.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 static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.xml.sax.helpers.DefaultHandler;

import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;

/**
 * HEIF/HEIC XMP was always dropped ��� metadata-extractor never read it. Stored as an
 * {@code application/rdf+xml} item but usually {@code <?xpacket?>}-wrapped, so the shared packet
 * scanner catches it (same path as TIFF/PSD/JXL). Fixture is a hand-built HEIC, no corpus binary.
 */
public class HeicXmpTest {

    private static final String XMP =
            "<?xpacket begin=\"���\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>"
            + "<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:dc=\"http://purl.org/dc/elements/1.1/\">"
            + "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">HEIC XMP Title</rdf:li>"
            + "</rdf:Alt></dc:title>"
            + "<dc:creator><rdf:Seq><rdf:li>Jane Photographer</rdf:li></rdf:Seq></dc:creator>"
            + "</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";

    /** Same content but with NO xpacket wrapper -> the scanner misses it, the item locator wins. */
    private static final String BARE_XMP =
            "<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:dc=\"http://purl.org/dc/elements/1.1/\">"
            + "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Bare HEIC Title</rdf:li>"
            + "</rdf:Alt></dc:title>"
            + "<dc:creator><rdf:Seq><rdf:li>Jane Photographer</rdf:li></rdf:Seq></dc:creator>"
            + "</rdf:Description></rdf:RDF></x:xmpmeta>";

    private static byte[] u32(int v) {
        return new byte[]{(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
    }

    private static byte[] u16(int v) {
        return new byte[]{(byte) (v >>> 8), (byte) v};
    }

    private static byte[] concat(byte[]... parts) {
        ByteArrayOutputStream b = new ByteArrayOutputStream();
        for (byte[] p : parts) {
            b.writeBytes(p);
        }
        return b.toByteArray();
    }

    private static byte[] box(String type, byte[] payload) {
        ByteArrayOutputStream b = new ByteArrayOutputStream();
        b.writeBytes(u32(8 + payload.length));
        b.writeBytes(type.getBytes(US_ASCII));
        b.writeBytes(payload);
        return b.toByteArray();
    }

    private static byte[] fullBox(String type, byte[] payload) {
        return fullBox(type, 0, payload);
    }

    private static byte[] fullBox(String type, int version, byte[] payload) {
        ByteArrayOutputStream b = new ByteArrayOutputStream();
        b.write(version);
        b.writeBytes(new byte[3]);   // flags
        b.writeBytes(payload);
        return box(type, b.toByteArray());
    }

    /** ftyp(heic) + minimal meta(hdlr=pict) + mdat holding the XMP packet. */
    private static byte[] heicWithXmp() {
        ByteArrayOutputStream ftypPayload = new ByteArrayOutputStream();
        ftypPayload.writeBytes("heic".getBytes(US_ASCII));   // major brand
        ftypPayload.writeBytes(u32(0));                       // minor version
        ftypPayload.writeBytes("mif1".getBytes(US_ASCII));    // compatible brands
        ftypPayload.writeBytes("heic".getBytes(US_ASCII));

        ByteArrayOutputStream hdlrPayload = new ByteArrayOutputStream();
        hdlrPayload.writeBytes(u32(0));                        // pre_defined
        hdlrPayload.writeBytes("pict".getBytes(US_ASCII));    // handler_type
        hdlrPayload.writeBytes(new byte[13]);                 // reserved[3] + empty name

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.writeBytes(box("ftyp", ftypPayload.toByteArray()));
        out.writeBytes(fullBox("meta", fullBox("hdlr", hdlrPayload.toByteArray())));
        out.writeBytes(box("mdat", XMP.getBytes(UTF_8)));
        return out.toByteArray();
    }

    /**
     * ftyp(heic) + meta(hdlr, iinf declaring a mime/application/rdf+xml item, iloc pointing at
     * mdat) + mdat holding a bare (non-xpacket) XMP packet. Exercises the iinf/iloc item locator.
     */
    private static byte[] bareItemHeic() {
        byte[] xmp = BARE_XMP.getBytes(UTF_8);
        byte[] ftyp = box("ftyp", concat("heic".getBytes(US_ASCII), u32(0),
                "mif1".getBytes(US_ASCII), "heic".getBytes(US_ASCII)));
        byte[] hdlr = fullBox("hdlr", concat(u32(0), "pict".getBytes(US_ASCII), new byte[13]));
        // infe v2: item_ID=1, protection=0, type=mime, empty name, content_type + null terminator
        byte[] infe = fullBox("infe", 2, concat(u16(1), u16(0), "mime".getBytes(US_ASCII),
                new byte[]{0}, "application/rdf+xml".getBytes(US_ASCII), new byte[]{0}));
        byte[] iinf = fullBox("iinf", concat(u16(1), infe));

        // The extent offset is a fixed-width field, so meta's size does not depend on its value:
        // build once with a placeholder to learn meta's length, then again with the real offset.
        int metaLen = fullBox("meta", concat(hdlr, iinf, iloc(0, xmp.length))).length;
        int xmpOffset = ftyp.length + metaLen + 8;   // + mdat header
        byte[] meta = fullBox("meta", concat(hdlr, iinf, iloc(xmpOffset, xmp.length)));
        return concat(ftyp, meta, box("mdat", xmp));
    }

    /** iloc v0: offset/length size 4, no base offset; one item, one extent. */
    private static byte[] iloc(int offset, int length) {
        return fullBox("iloc", concat(
                new byte[]{0x44, 0x00},   // offsetSize=4, lengthSize=4; baseOffsetSize=0
                u16(1),                   // item_count
                u16(1), u16(0), u16(1),   // item_ID, data_reference_index, extent_count
                u32(offset), u32(length)));
    }

    @Test
    public void testHeicXmpIsExtracted(@TempDir Path tmp) throws Exception {
        Path file = tmp.resolve("xmp.heic");
        Files.write(file, heicWithXmp());

        Metadata metadata = new Metadata();
        metadata.set(Metadata.CONTENT_TYPE, "image/heic");
        try (TikaInputStream tis = TikaInputStream.get(file)) {
            new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
        }

        assertEquals("HEIC XMP Title", metadata.get(TikaCoreProperties.TITLE));
        assertEquals("HEIC XMP Title", metadata.get("dc:title"));
        assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR));
        assertEquals("Jane Photographer", metadata.get("dc:creator"));
    }

    /** XMP with no xpacket wrapper is found by locating the rdf+xml item via meta/iinf/iloc. */
    @Test
    public void testBareRdfItemXmpIsExtracted(@TempDir Path tmp) throws Exception {
        Path file = tmp.resolve("bare.heic");
        Files.write(file, bareItemHeic());

        Metadata metadata = new Metadata();
        metadata.set(Metadata.CONTENT_TYPE, "image/heic");
        try (TikaInputStream tis = TikaInputStream.get(file)) {
            new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext());
        }

        assertEquals("Bare HEIC Title", metadata.get(TikaCoreProperties.TITLE));
        assertEquals("Bare HEIC Title", metadata.get("dc:title"));
        assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR));
    }
}