XmpExtractorTest.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.xmp;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.PDF;
import org.apache.tika.metadata.PagedText;
import org.apache.tika.metadata.Photoshop;
import org.apache.tika.metadata.TIFF;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMP;
import org.apache.tika.metadata.XMPDC;
import org.apache.tika.metadata.XMPMM;
import org.apache.tika.metadata.XMPTIFF;

public class XmpExtractorTest {

    private static final String PACKET =
            "<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Test 1.0'>"
          + "<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'"
          + "         xmlns:xmp='http://ns.adobe.com/xap/1.0/'"
          + "         xmlns:xmpMM='http://ns.adobe.com/xap/1.0/mm/'"
          + "         xmlns:stRef='http://ns.adobe.com/xap/1.0/sType/ResourceRef#'"
          + "         xmlns:stEvt='http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'"
          + "         xmlns:tiff='http://ns.adobe.com/tiff/1.0/'"
          + "         xmlns:crs='http://ns.adobe.com/camera-raw-settings/1.0/'"
          + "         xmlns:dc='http://purl.org/dc/elements/1.1/'>"
          + "<rdf:Description rdf:about='' tiff:Make='Canon' crs:Contrast='25'>"
          + "  <xmp:CreateDate>2020-01-02T03:04:05Z</xmp:CreateDate>"
          + "  <dc:creator><rdf:Seq><rdf:li>Alice</rdf:li><rdf:li>Bob</rdf:li></rdf:Seq></dc:creator>"
          + "  <dc:title><rdf:Alt><rdf:li xml:lang='x-default'>Hello</rdf:li></rdf:Alt></dc:title>"
          + "  <xmpMM:DerivedFrom rdf:parseType='Resource'>"
          + "    <stRef:documentID>docid-123</stRef:documentID>"
          + "    <stRef:originalDocumentID>orig-999</stRef:originalDocumentID>"
          + "    <stRef:renditionClass>proof:pdf</stRef:renditionClass>"
          + "  </xmpMM:DerivedFrom>"
          + "  <xmpMM:History><rdf:Seq>"
          + "    <rdf:li rdf:parseType='Resource'><stEvt:action>created</stEvt:action>"
          + "      <stEvt:changed>/metadata</stEvt:changed>"
          + "      <stEvt:parameters>from application/pdf</stEvt:parameters></rdf:li>"
          + "    <rdf:li rdf:parseType='Resource'><stEvt:action>saved</stEvt:action></rdf:li>"
          + "  </rdf:Seq></xmpMM:History>"
          + "</rdf:Description></rdf:RDF></x:xmpmeta>";

    private Metadata metadata;

    @BeforeEach
    public void setUp() throws Exception {
        metadata = new Metadata();
        new XmpExtractor().extract(PACKET.getBytes(UTF_8), metadata);
    }

    @Test
    public void testScalarsAndDates() {
        assertEquals("Hello", metadata.get(TikaCoreProperties.TITLE));
        assertEquals("2020-01-02T03:04:05Z", metadata.get(XMP.CREATE_DATE));
        // xmp:CreateDate also fills the canonical created date when nothing else set it
        assertEquals("2020-01-02T03:04:05Z", metadata.get(TikaCoreProperties.CREATED));
    }

    /** Canonical created is filled set-if-absent: a doc date (docinfo/EXIF) already present wins. */
    @Test
    public void testCanonicalCreatedNotOverwritten() throws Exception {
        Metadata md = new Metadata();
        md.set(TikaCoreProperties.CREATED, "1999-12-31T00:00:00Z");   // e.g. docinfo/EXIF got here first
        new XmpExtractor().extract(PACKET.getBytes(UTF_8), md);
        assertEquals("1999-12-31T00:00:00Z", md.get(TikaCoreProperties.CREATED));   // unchanged
        assertEquals("2020-01-02T03:04:05Z", md.get(XMP.CREATE_DATE));              // xmp key still set
    }

    @Test
    public void testMultiValued() {
        assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(TikaCoreProperties.CREATOR));
    }

    @Test
    public void testStructs() {
        assertEquals("docid-123", metadata.get(XMPMM.DERIVED_FROM_DOCUMENTID));
        assertArrayEquals(new String[]{"created", "saved"}, metadata.getValues(XMPMM.HISTORY_ACTION));
    }

    /** History/DerivedFrom subfields beyond action/when/documentID map to dedicated properties. */
    @Test
    public void testHistoryAndDerivedFromSubfields() {
        assertEquals("/metadata", metadata.get(XMPMM.HISTORY_CHANGED));
        assertEquals("from application/pdf", metadata.get(XMPMM.HISTORY_PARAMETERS));
        assertEquals("orig-999", metadata.get(XMPMM.DERIVED_FROM_ORIGINAL_DOCUMENTID));
        assertEquals("proof:pdf", metadata.get(XMPMM.DERIVED_FROM_RENDITION_CLASS));
    }

    @Test
    public void testUnmappedPassthrough() {
        assertEquals("25", metadata.get("xmp-raw:crs:Contrast"));   // still-unmapped -> namespaced raw
        assertEquals("Canon", metadata.get(TIFF.EQUIPMENT_MAKE));   // tiff:Make is now promoted
    }

    /** Group-1 + Group-2(clean) + aux promotions: top raw keys map to first-class properties. */
    @Test
    public void testPromotedKeys() 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:photoshop='http://ns.adobe.com/photoshop/1.0/'"
                + "   xmlns:xmpTPg='http://ns.adobe.com/xap/1.0/t/pg/'"
                + "   xmlns:pdf='http://ns.adobe.com/pdf/1.3/'"
                + "   xmlns:tiff='http://ns.adobe.com/tiff/1.0/'"
                + "   xmlns:exif='http://ns.adobe.com/exif/1.0/'"
                + "   xmlns:aux='http://ns.adobe.com/exif/1.0/aux/'"
                + "   photoshop:ColorMode='3'"
                + "   photoshop:ICCProfile='sRGB IEC61966-2.1'"
                + "   photoshop:DateCreated='2011-08-05T10:48:16-05:00'"
                + "   xmpTPg:NPages='7'"
                + "   pdf:Trapped='False'"
                + "   tiff:Make='Canon'"
                + "   tiff:Model='EOS 5D'"
                + "   tiff:Orientation='1'"
                + "   exif:DateTimeOriginal='2005-04-06T10:34:58-04:00'"
                + "   exif:ISOSpeedRatings='64'"
                + "   exif:PixelXDimension='600'"
                + "   exif:PixelYDimension='734'"
                + "   aux:SerialNumber='142026001685'"
                + "   aux:Lens='iPhone 5s back camera 4.12mm f/2.2'/>"
                + "</rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);

        // group 1
        assertEquals("3", md.get(Photoshop.COLOR_MODE));
        assertEquals("sRGB IEC61966-2.1", md.get(Photoshop.ICC_PROFILE));
        assertEquals("7", md.get(PagedText.N_PAGES));
        assertEquals("False", md.get(PDF.TRAPPED));
        assertNotNull(md.get(Photoshop.DATE_CREATED));                 // date-typed, normalized
        assertNotNull(md.get(TikaCoreProperties.CREATED));             // DateCreated fills created
        // group 2 (clean exif/tiff) + aux -> the shared TIFF interface
        assertEquals("Canon", md.get(TIFF.EQUIPMENT_MAKE));
        assertEquals("EOS 5D", md.get(TIFF.EQUIPMENT_MODEL));
        assertEquals("1", md.get(TIFF.ORIENTATION));
        assertNotNull(md.get(TIFF.ORIGINAL_DATE));                     // exif:DateTimeOriginal, normalized
        assertEquals("64", md.get(TIFF.ISO_SPEED_RATINGS));
        assertEquals("142026001685", md.get(TIFF.SERIAL_NUMBER));
        assertEquals("iPhone 5s back camera 4.12mm f/2.2", md.get(TIFF.LENS));
        // exif:Pixel*Dimension folds into the canonical IMAGE_WIDTH/LENGTH (+ xmp-marked variant)
        assertEquals("600", md.get(TIFF.IMAGE_WIDTH));
        assertEquals("734", md.get(TIFF.IMAGE_LENGTH));
        assertEquals("600", md.get(XMPTIFF.PIXEL_X_DIMENSION));
        // tiff:/exif: are double-keyed: the XMP-marked variant preserves provenance
        assertEquals("Canon", md.get(XMPTIFF.EQUIPMENT_MAKE));
        assertEquals("EOS 5D", md.get(XMPTIFF.EQUIPMENT_MODEL));
        assertNotNull(md.get(XMPTIFF.ORIGINAL_DATE));
        // aux: is single-key (no binary source) -> no xmp-marked variant
        assertNull(md.get("xmp:aux:Lens"));
        // no longer raw
        assertNull(md.get("xmp-raw:photoshop:ColorMode"));
        assertNull(md.get("xmp-raw:tiff:Make"));
        assertNull(md.get("xmp-raw:aux:Lens"));
    }

    /** A bare rdf:li outside a Bag/Seq/Alt (malformed) must not NPE / crash the parse. */
    @Test
    public void testBareRdfLiIsNotFatal() 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:dc='http://purl.org/dc/elements/1.1/'>"
                + "<dc:subject><rdf:li>lonely</rdf:li></dc:subject>"   // rdf:li with no container
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);   // must not throw
        assertEquals("lonely", md.get(TikaCoreProperties.SUBJECT));
    }

    /** A property nested in a struct must not overwrite the document-level one (falls to raw). */
    @Test
    public void testNestedPropertyDoesNotClobberDocumentLevel() 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:xmpMM='http://ns.adobe.com/xap/1.0/mm/'"
                + "   xmpMM:InstanceID='doc-level'>"
                + "  <xmpMM:Pantry><rdf:Bag><rdf:li rdf:parseType='Resource'>"
                + "    <xmpMM:InstanceID>pantry-ingredient</xmpMM:InstanceID>"
                + "  </rdf:li></rdf:Bag></xmpMM:Pantry>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        assertEquals("doc-level", md.get(XMPMM.INSTANCEID));   // not clobbered by the nested one
        assertNotNull(md.get("xmp-raw:xmpMM:Pantry[1]/xmpMM:InstanceID"));   // nested falls to raw
    }

    /** rdf:value carries the property's value even when qualifier siblings are present. */
    @Test
    public void testRdfValueWithQualifier() 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:xmp='http://ns.adobe.com/xap/1.0/'"
                + "   xmlns:xmpidq='http://ns.adobe.com/xmp/Identifier/qual/1.0/'>"
                + "  <xmp:Identifier rdf:parseType='Resource'>"
                + "    <rdf:value>the-id</rdf:value>"
                + "    <xmpidq:Scheme>myscheme</xmpidq:Scheme>"
                + "  </xmp:Identifier>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        assertEquals("the-id", md.get(XMP.IDENTIFIER));   // value kept despite the qualifier sibling
    }

    /** dc:title is a text bag: every language accumulates on the canonical key (TIKA-1295/4466). */
    @Test
    public void testMultiLangTitleAccumulates() 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:dc='http://purl.org/dc/elements/1.1/'>"
                + "<dc:title><rdf:Alt>"
                + "<rdf:li xml:lang='fr'>Bonjour</rdf:li>"
                + "<rdf:li xml:lang='x-default'>Hello</rdf:li>"
                + "<rdf:li xml:lang='es'>Hola</rdf:li>"
                + "</rdf:Alt></dc:title>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        // x-default ("Hello") leads the bag even though it is listed second in the packet, so
        // metadata.get(TITLE) (== values[0]) is the default; the rest follow in document order.
        assertArrayEquals(new String[]{"Hello", "Bonjour", "Hola"}, md.getValues(TikaCoreProperties.TITLE));
        assertEquals("Hello", md.get(TikaCoreProperties.TITLE));
        assertEquals("Bonjour", md.get("dc:title:fr"));
        assertEquals("Hello", md.get("dc:title:x-default"));
        assertEquals("Hola", md.get("dc:title:es"));
    }

    /** A single-valued Alt property (xmp:Title -> XMP.TITLE) takes x-default, not the last li. */
    @Test
    public void testSingleValuedLangAltIsXDefault() 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:xmp='http://ns.adobe.com/xap/1.0/'>"
                + "<xmp:Title><rdf:Alt>"
                + "<rdf:li xml:lang='fr'>Titre</rdf:li>"
                + "<rdf:li xml:lang='x-default'>Title</rdf:li>"
                + "</rdf:Alt></xmp:Title>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        assertEquals("Title", md.get(XMP.TITLE));   // x-default, not the last alternative (Titre)
    }

    /** xmpMM:History parallel bags stay index-aligned: a missing field is padded so ACTION[i]~WHEN[i]. */
    @Test
    public void testHistoryParallelArraysStayAligned() 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#'"
                + "   xmlns:xmpMM='http://ns.adobe.com/xap/1.0/mm/'"
                + "   xmlns:stEvt='http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'>"
                + "<rdf:Description rdf:about=''>"
                + "<xmpMM:History><rdf:Seq>"
                + "<rdf:li rdf:parseType='Resource'><stEvt:action>created</stEvt:action>"
                + "  <stEvt:when>2020-01-01T00:00:00Z</stEvt:when></rdf:li>"
                + "<rdf:li rdf:parseType='Resource'><stEvt:action>saved</stEvt:action></rdf:li>"
                + "<rdf:li rdf:parseType='Resource'><stEvt:action>printed</stEvt:action>"
                + "  <stEvt:when>2021-02-02T00:00:00Z</stEvt:when></rdf:li>"
                + "</rdf:Seq></xmpMM:History>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        assertArrayEquals(new String[]{"created", "saved", "printed"},
                md.getValues(XMPMM.HISTORY_ACTION));
        // the second event ("saved") has no when, so WHEN[1] is padded to keep the arrays aligned
        assertArrayEquals(new String[]{"2020-01-01T00:00:00Z", "", "2021-02-02T00:00:00Z"},
                md.getValues(XMPMM.HISTORY_WHEN));
    }

    /** Adobe-internal digests and the base64 thumbnail blob are dropped, not exposed even as raw. */
    @Test
    public void testJunkKeysDropped() 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:tiff='http://ns.adobe.com/tiff/1.0/'"
                + "   xmlns:exif='http://ns.adobe.com/exif/1.0/'"
                + "   xmlns:xmp='http://ns.adobe.com/xap/1.0/'"
                + "   xmlns:xmpGImg='http://ns.adobe.com/xap/1.0/g/img/'>"
                + "  <tiff:NativeDigest>256,257,258;A1B2C3</tiff:NativeDigest>"
                + "  <exif:NativeDigest>36864,40960;123456</exif:NativeDigest>"
                + "  <xmp:Thumbnails><rdf:Alt><rdf:li rdf:parseType='Resource'>"
                + "    <xmpGImg:image>/9j/4AAQSkZJRgABAgEBLAEsAAD</xmpGImg:image>"
                + "    <xmpGImg:width>256</xmpGImg:width>"
                + "    <xmpGImg:format>JPEG</xmpGImg:format>"
                + "  </rdf:li></rdf:Alt></xmp:Thumbnails>"
                + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);

        boolean nativeDigest = false;
        boolean blob = false;
        boolean widthKept = false;
        for (String n : md.names()) {
            if (n.contains("NativeDigest")) {
                nativeDigest = true;
            }
            if (md.get(n).startsWith("/9j/")) {
                blob = true;
            }
            if (n.endsWith("xmpGImg:width")) {
                widthKept = true;
            }
        }
        assertFalse(nativeDigest, "tiff/exif:NativeDigest must be dropped");
        assertFalse(blob, "base64 thumbnail blob must be dropped");
        assertTrue(widthKept, "harmless thumbnail siblings are kept");
    }

    /** Non-ASCII UTF-8 values must survive the SAX flatten/extract path intact. */
    @Test
    public void testNonAsciiUtf8() throws Exception {
        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#'"
              + "         xmlns:dc='http://purl.org/dc/elements/1.1/'>"
              + "<rdf:Description rdf:about=''><dc:title><rdf:Alt>"
              + "<rdf:li xml:lang='x-default'>Tosteberga ��ngar</rdf:li>"
              + "</rdf:Alt></dc:title></rdf:Description></rdf:RDF></x:xmpmeta>"
              + "<?xpacket end=\"w\"?>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(xmp.getBytes(java.nio.charset.StandardCharsets.UTF_8), md);
        assertEquals("Tosteberga ��ngar", md.get(TikaCoreProperties.TITLE));
    }

    /** Raw passthrough is namespaced (xmp-raw:) so untrusted XMP can never shadow a known Tika field. */
    @Test
    public void testPassthroughCannotShadowKnownFields() throws Exception {
        String evil =
                "<x:xmpmeta xmlns:x='adobe:ns:meta/'>"
              + "<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'"
              + "         xmlns:dc='http://evil.example/'"     // spoofed dc URI -> table miss -> raw
              + "         xmlns:ev='http://evil.example/2'>"
              + "<rdf:Description rdf:about=''>"
              + "  <dc:title>injected</dc:title>"
              + "  <ev:harmless>ok</ev:harmless>"
              + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(evil.getBytes(UTF_8), md);
        assertNull(md.get(TikaCoreProperties.TITLE), "spoofed dc:title must not write the known field");
        assertNull(md.get("dc:title"));
        assertEquals("injected", md.get("xmp-raw:dc:title"));   // it lands under the raw namespace instead
        assertEquals("ok", md.get("xmp-raw:ev:harmless"));
    }

    /**
     * A raw (unmapped) bag collapses to one multi-valued xmp-raw key; interior indices in an
     * array-of-structs are kept so element fields stay distinguishable.
     */
    @Test
    public void testRawArrayKeys() 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#'"
              + "         xmlns:foo='http://ns.example.com/foo/1.0/'>"
              + "<rdf:Description rdf:about=''>"
              + "<foo:Tags><rdf:Bag>"
              + "<rdf:li>alpha</rdf:li><rdf:li>beta</rdf:li><rdf:li>gamma</rdf:li>"
              + "</rdf:Bag></foo:Tags>"
              + "<foo:Events><rdf:Seq>"
              + "<rdf:li rdf:parseType='Resource'><foo:name>e1</foo:name></rdf:li>"
              + "<rdf:li rdf:parseType='Resource'><foo:name>e2</foo:name></rdf:li>"
              + "</rdf:Seq></foo:Events>"
              + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        // bag -> one multi-valued key, no per-index keys
        assertArrayEquals(new String[]{"alpha", "beta", "gamma"}, md.getValues("xmp-raw:foo:Tags"));
        assertNull(md.get("xmp-raw:foo:Tags[1]"));
        // array-of-structs -> interior indices preserved so e1/e2 stay separate
        assertEquals("e1", md.get("xmp-raw:foo:Events[1]/foo:name"));
        assertEquals("e2", md.get("xmp-raw:foo:Events[2]/foo:name"));
    }

    /** Option A: dc values land in both the canonical and the xmp-namespaced property. */
    @Test
    public void testDoubleKeying() {
        assertEquals("Hello", metadata.get(XMPDC.TITLE));
        assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(XMPDC.CREATOR));
    }

    /** Language-alt items are exposed under key:lang, for both canonical and xmp-namespaced keys. */
    @Test
    public void testLanguageVariants() throws Exception {
        String ml =
                "<x:xmpmeta xmlns:x='adobe:ns:meta/'>"
              + "<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'"
              + "         xmlns:dc='http://purl.org/dc/elements/1.1/'>"
              + "<rdf:Description rdf:about=''><dc:title><rdf:Alt>"
              + "  <rdf:li xml:lang='x-default'>Hello World</rdf:li>"
              + "  <rdf:li xml:lang='fr-ca'>Bonjour World</rdf:li>"
              + "</rdf:Alt></dc:title></rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(ml.getBytes(UTF_8), md);
        assertEquals("Hello World", md.get("dc:title:x-default"));
        assertEquals("Bonjour World", md.get("dc:title:fr-ca"));
        assertEquals("Bonjour World", md.get("xmp:dc:title:fr-ca"));   // Option A: xmp-namespaced too
    }

    /** xmpMM:History is capped (MAX_HISTORY_EVENTS) so a hostile packet can't inflate metadata. */
    @Test
    public void testHistoryEventsAreCapped() throws Exception {
        StringBuilder sb = new StringBuilder(
                "<x:xmpmeta xmlns:x='adobe:ns:meta/'>"
              + "<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'"
              + "         xmlns:xmpMM='http://ns.adobe.com/xap/1.0/mm/'"
              + "         xmlns:stEvt='http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'>"
              + "<rdf:Description rdf:about=''><xmpMM:History><rdf:Seq>");
        for (int i = 0; i < 1100; i++) {
            sb.append("<rdf:li rdf:parseType='Resource'><stEvt:action>saved</stEvt:action></rdf:li>");
        }
        sb.append("</rdf:Seq></xmpMM:History></rdf:Description></rdf:RDF></x:xmpmeta>");
        Metadata md = new Metadata();
        new XmpExtractor().extract(sb.toString().getBytes(UTF_8), md);
        assertEquals(1024, md.getValues(XMPMM.HISTORY_ACTION).length);   // capped, not 1100
    }

    /** rdf:about is populated to xmp:About when non-empty, and skipped when empty. */
    @Test
    public void testRdfAboutMapsToXmpAbout() throws Exception {
        String withAbout =
                "<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='uuid:doc-42' xmlns:dc='http://purl.org/dc/elements/1.1/'>"
              + "<dc:format>application/pdf</dc:format>"
              + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(withAbout.getBytes(UTF_8), md);
        assertEquals("uuid:doc-42", md.get(XMP.ABOUT));

        Metadata md2 = new Metadata();
        new XmpExtractor().extract(withAbout.replace("uuid:doc-42", "").getBytes(UTF_8), md2);
        assertNull(md2.get(XMP.ABOUT), "empty rdf:about must not be recorded");
    }

    /** Two rdf:RDF blocks in one packet are both processed. */
    @Test
    public void testMultipleRdfBlocks() 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#'"
              + "         xmlns:dc='http://purl.org/dc/elements/1.1/'>"
              + "<rdf:Description rdf:about=''>"
              + "<dc:title><rdf:Alt><rdf:li xml:lang='x-default'>First Block</rdf:li></rdf:Alt></dc:title>"
              + "</rdf:Description></rdf:RDF>"
              + "<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'"
              + "         xmlns:dc='http://purl.org/dc/elements/1.1/'>"
              + "<rdf:Description rdf:about=''>"
              + "<dc:creator><rdf:Seq><rdf:li>Second Block</rdf:li></rdf:Seq></dc:creator>"
              + "</rdf:Description></rdf:RDF></x:xmpmeta>";
        Metadata md = new Metadata();
        new XmpExtractor().extract(packet.getBytes(UTF_8), md);
        assertEquals("First Block", md.get(TikaCoreProperties.TITLE));
        assertEquals("Second Block", md.get(TikaCoreProperties.CREATOR));
    }
}