XMPReaderTest.java

/*
 * Copyright (c) 2012, Harald Kuhr
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * * Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.twelvemonkeys.imageio.metadata.xmp;

import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;

import javax.imageio.ImageIO;
import javax.imageio.stream.ImageInputStream;

import com.twelvemonkeys.imageio.stream.DirectImageInputStream;

import com.twelvemonkeys.imageio.metadata.CompoundDirectory;
import com.twelvemonkeys.imageio.metadata.Directory;
import com.twelvemonkeys.imageio.metadata.Entry;
import com.twelvemonkeys.imageio.metadata.MetadataReaderAbstractTest;

/**
 * XMPReaderTest
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @author last modified by $Author: haraldk$
 * @version $Id: XMPReaderTest.java,v 1.0 04.01.12 10:47 haraldk Exp$
 */
public class XMPReaderTest extends MetadataReaderAbstractTest {

    @Override
    protected InputStream getData() throws IOException {
        return getResource("/xmp/xmp-jpeg-example.xml").openStream();
    }

    private ImageInputStream getResourceAsIIS(final String name) throws IOException {
        return ImageIO.createImageInputStream(getResource(name));
    }

    @Override
    protected XMPReader createReader() {
       return new XMPReader();
    }

    @Test
    public void testDirectoryContent() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertEquals(29, directory.size());

        // photoshop|http://ns.adobe.com/photoshop/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/DateCreated"), hasValue("2008-07-01"));
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/ColorMode"), hasValue("4"));
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/ICCProfile"), hasValue("U.S. Web Coated (SWOP) v2"));
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/History"), hasValue(""));

        // xapMM|http://ns.adobe.com/xap/1.0/mm/
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DocumentID"), hasValue("uuid:54A8D5F8654711DD9226A85E1241887A"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/InstanceID"), hasValue("uuid:54A8D5F9654711DD9226A85E1241887A"));

        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DerivedFrom"), hasValue(
                new RDFDescription(Arrays.asList(
                        // stRef|http://ns.adobe.com/xap/1.0/sType/ResourceRef#
                        new XMPEntry("http://ns.adobe.com/xap/1.0/sType/ResourceRef#instanceID", "instanceID", "uuid:3B52F3610F49DD118831FCA29C13B8DE"),
                        new XMPEntry("http://ns.adobe.com/xap/1.0/sType/ResourceRef#documentID", "documentID", "uuid:3A52F3610F49DD118831FCA29C13B8DE")
                ))
        ));

        // dc|http://purl.org/dc/elements/1.1/
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/description"), hasValue(Collections.singletonMap("x-default", "Picture 71146")));
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/format"), hasValue("image/jpeg"));

        // tiff|http://ns.adobe.com/tiff/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/ImageWidth"), hasValue("3601"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/ImageLength"), hasValue("4176"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/BitsPerSample"), hasValue(Arrays.asList("8", "8", "8")));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/Compression"), hasValue("1"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/PhotometricInterpretation"), hasValue("2"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/SamplesPerPixel"), hasValue("3"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/PlanarConfiguration"), hasValue("1"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/XResolution"), hasValue("3000000/10000"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/YResolution"), hasValue("3000000/10000"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/ResolutionUnit"), hasValue("2"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/Orientation"), hasValue("1"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/NativeDigest"), hasValue("256,257,258,259,262,274,277,284,530,531,282,283,296,301,318,319,529,532,306,270,271,272,305,315,33432;C21EE6D33E4CCA3712ECB1F5E9031A49"));

        // xap|http://ns.adobe.com/xap/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/ModifyDate"), hasValue("2008-08-06T12:43:05+10:00"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/CreatorTool"), hasValue("Adobe Photoshop CS2 Macintosh"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/CreateDate"), hasValue("2008-08-06T12:43:05+10:00"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/MetadataDate"), hasValue("2008-08-06T12:43:05+10:00"));

        // exif|http://ns.adobe.com/exif/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/ColorSpace"), hasValue("-1")); // SIC
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/PixelXDimension"), hasValue("3601"));
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/PixelYDimension"), hasValue("4176"));
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/NativeDigest"), hasValue("36864,40960,40961,37121,37122,40962,40963,37510,40964,36867,36868,33434,33437,34850,34852,34855,34856,37377,37378,37379,37380,37381,37382,37383,37384,37385,37386,37396,41483,41484,41486,41487,41488,41492,41493,41495,41728,41729,41730,41985,41986,41987,41988,41989,41990,41991,41992,41993,41994,41995,41996,42016,0,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20,22,23,24,25,26,27,28,30;297AD344CC15F29D5283460ED026368F"));
    }

    @Test
    public void testCompoundDirectory() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;
        assertEquals(6, compound.directoryCount());

        int size = 0;
        for (int i = 0; i < compound.directoryCount(); i++) {
            Directory sub = compound.getDirectory(i);
            assertNotNull(sub);
            size += sub.size();
        }

        assertEquals(directory.size(), size);
    }

    @Test
    public void testCompoundDirectoryContentPhotoshop() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // photoshop|http://ns.adobe.com/photoshop/1.0/
        Directory photoshop = compound.getDirectory(0);
        assertEquals(4, photoshop.size());
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/DateCreated"), hasValue("2008-07-01"));
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/ColorMode"), hasValue("4"));
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/ICCProfile"), hasValue("U.S. Web Coated (SWOP) v2"));
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/History"), hasValue(""));

    }

    @Test
    public void testCompoundDirectoryContentMM() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // xapMM|http://ns.adobe.com/xap/1.0/mm/
        Directory mm = compound.getDirectory(1);
        assertEquals(3, mm.size());
        assertThat(mm.getEntryById("http://ns.adobe.com/xap/1.0/mm/DocumentID"), hasValue("uuid:54A8D5F8654711DD9226A85E1241887A"));
        assertThat(mm.getEntryById("http://ns.adobe.com/xap/1.0/mm/InstanceID"), hasValue("uuid:54A8D5F9654711DD9226A85E1241887A"));
        assertThat(mm.getEntryById("http://ns.adobe.com/xap/1.0/mm/DerivedFrom"), hasValue(
                new RDFDescription(Arrays.asList(
                        // stRef|http://ns.adobe.com/xap/1.0/sType/ResourceRef#
                        new XMPEntry("http://ns.adobe.com/xap/1.0/sType/ResourceRef#instanceID", "instanceID", "uuid:3B52F3610F49DD118831FCA29C13B8DE"),
                        new XMPEntry("http://ns.adobe.com/xap/1.0/sType/ResourceRef#documentID", "documentID", "uuid:3A52F3610F49DD118831FCA29C13B8DE")
                ))
        ));

    }

    @Test
    public void testCompoundDirectoryContentDC() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // dc|http://purl.org/dc/elements/1.1/
        Directory dc = compound.getDirectory(2);
        assertEquals(2, dc.size());
        assertThat(dc.getEntryById("http://purl.org/dc/elements/1.1/description"), hasValue(Collections.singletonMap("x-default", "Picture 71146")));
        assertThat(dc.getEntryById("http://purl.org/dc/elements/1.1/format"), hasValue("image/jpeg"));

    }

    @Test
    public void testCompoundDirectoryContentTIFF() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // tiff|http://ns.adobe.com/tiff/1.0/
        Directory tiff = compound.getDirectory(3);
        assertEquals(12, tiff.size());
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/ImageWidth"), hasValue("3601"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/ImageLength"), hasValue("4176"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/BitsPerSample"), hasValue(Arrays.asList("8", "8", "8")));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/Compression"), hasValue("1"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/PhotometricInterpretation"), hasValue("2"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/SamplesPerPixel"), hasValue("3"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/PlanarConfiguration"), hasValue("1"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/XResolution"), hasValue("3000000/10000"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/YResolution"), hasValue("3000000/10000"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/ResolutionUnit"), hasValue("2"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/Orientation"), hasValue("1"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/NativeDigest"), hasValue("256,257,258,259,262,274,277,284,530,531,282,283,296,301,318,319,529,532,306,270,271,272,305,315,33432;C21EE6D33E4CCA3712ECB1F5E9031A49"));
    }

    @Test
    public void testCompoundDirectoryContentXAP() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // xap|http://ns.adobe.com/xap/1.0/
        Directory xap = compound.getDirectory(4);
        assertEquals(4, xap.size());
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/ModifyDate"), hasValue("2008-08-06T12:43:05+10:00"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/CreatorTool"), hasValue("Adobe Photoshop CS2 Macintosh"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/CreateDate"), hasValue("2008-08-06T12:43:05+10:00"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/MetadataDate"), hasValue("2008-08-06T12:43:05+10:00"));
    }

    @Test
    public void testCompoundDirectoryContentEXIF() throws IOException {
        Directory directory = createReader().read(getDataAsIIS());

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // exif|http://ns.adobe.com/exif/1.0/
        Directory exif = compound.getDirectory(5);
        assertEquals(4, exif.size());
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/ColorSpace"), hasValue("-1")); // SIC. Same as unsigned short 65535, meaning "uncalibrated"?
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/PixelXDimension"), hasValue("3601"));
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/PixelYDimension"), hasValue("4176"));
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/NativeDigest"), hasValue("36864,40960,40961,37121,37122,40962,40963,37510,40964,36867,36868,33434,33437,34850,34852,34855,34856,37377,37378,37379,37380,37381,37382,37383,37384,37385,37386,37396,41483,41484,41486,41487,41488,41492,41493,41495,41728,41729,41730,41985,41986,41987,41988,41989,41990,41991,41992,41993,41994,41995,41996,42016,0,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20,22,23,24,25,26,27,28,30;297AD344CC15F29D5283460ED026368F"));
    }

    @Test
    public void testRDFBag() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-bag-example.xml"));

        assertEquals(1, directory.size());
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/subject"), hasValue(Arrays.asList("XMP", "metadata", "ISO standard"))); // Order does not matter
    }

    @Test
    public void testRDFSeq() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-seq-example.xml"));

        assertEquals(1, directory.size());
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/subject"), hasValue(Arrays.asList("XMP", "metadata", "ISO standard")));
    }
    
    @Test
    public void testRDFAlt() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-alt-example.xml"));

        assertEquals(1, directory.size());
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/subject"), hasValue(new HashMap<String, String>() {{
            put("x-default", "One");
            put("en-us", "One");
            put("de", "Ein");
            put("no-nb", "En");
        }}));
    }

    @Test
    public void testRDFAttributeSyntax() throws IOException {
        // Alternate RDF syntax, using attribute values instead of nested tags
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertEquals(20, directory.size());

        // dc|http://purl.org/dc/elements/1.1/
        assertThat(directory.getEntryById("http://purl.org/dc/elements/1.1/format"), hasValue("image/jpeg"));

        // xap|http://ns.adobe.com/xap/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/ModifyDate"), hasValue("2008-07-16T14:44:49-07:00"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/CreatorTool"), hasValue("Adobe Photoshop CS3 Windows"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/CreateDate"), hasValue("2008-07-16T14:44:49-07:00"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/MetadataDate"), hasValue("2008-07-16T14:44:49-07:00"));

        // tiff|http://ns.adobe.com/tiff/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/Orientation"), hasValue("1"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/XResolution"), hasValue("720000/10000"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/YResolution"), hasValue("720000/10000"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/ResolutionUnit"), hasValue("2"));
        assertThat(directory.getEntryById("http://ns.adobe.com/tiff/1.0/NativeDigest"), hasValue("256,257,258,259,262,274,277,284,530,531,282,283,296,301,318,319,529,532,306,270,271,272,305,315,33432;C08D8E93274C4BEE83E86CF999955A87"));

        // exif|http://ns.adobe.com/exif/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/ColorSpace"), hasValue("-1")); // SIC. Same as unsigned short 65535, meaning "uncalibrated"?
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/PixelXDimension"), hasValue("426"));
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/PixelYDimension"), hasValue("550"));
        assertThat(directory.getEntryById("http://ns.adobe.com/exif/1.0/NativeDigest"), hasValue("36864,40960,40961,37121,37122,40962,40963,37510,40964,36867,36868,33434,33437,34850,34852,34855,34856,37377,37378,37379,37380,37381,37382,37383,37384,37385,37386,37396,41483,41484,41486,41487,41488,41492,41493,41495,41728,41729,41730,41985,41986,41987,41988,41989,41990,41991,41992,41993,41994,41995,41996,42016,0,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20,22,23,24,25,26,27,28,30;A7F21D25E2C562F152B2C4ECC9E534DA"));

        // photoshop|http://ns.adobe.com/photoshop/1.0/
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/ColorMode"), hasValue("1"));
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/ICCProfile"), hasValue("Dot Gain 20%"));
        assertThat(directory.getEntryById("http://ns.adobe.com/photoshop/1.0/History"), hasValue(""));

        // xapMM|http://ns.adobe.com/xap/1.0/mm/
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DocumentID"), hasValue("uuid:6DCA50CC7D53DD119F20F5A7EA4C9BEC"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/InstanceID"), hasValue("uuid:6ECA50CC7D53DD119F20F5A7EA4C9BEC"));

        // Custom test, as NamedNodeMap does not preserve order (tests can't rely on XML impl specifics)
        Entry derivedFrom = directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DerivedFrom");
        assertNotNull(derivedFrom);
        assertThat(derivedFrom.getValue(), instanceOf(RDFDescription.class));

        // stRef|http://ns.adobe.com/xap/1.0/sType/ResourceRef#
        RDFDescription stRef = (RDFDescription) derivedFrom.getValue();
        assertThat(stRef.getEntryById("http://ns.adobe.com/xap/1.0/sType/ResourceRef#instanceID"), hasValue("uuid:74E1C905B405DD119306A1902BA5AA28"));
        assertThat(stRef.getEntryById("http://ns.adobe.com/xap/1.0/sType/ResourceRef#documentID"), hasValue("uuid:7A6C79768005DD119306A1902BA5AA28"));
    }

    @Test
    public void testRDFAttributeSyntaxCompound() throws IOException {
        // Alternate RDF syntax, using attribute values instead of nested tags
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;
        assertEquals(6, compound.directoryCount());

        int size = 0;
        for (int i = 0; i < compound.directoryCount(); i++) {
            Directory sub = compound.getDirectory(i);
            assertNotNull(sub);
            size += sub.size();
        }

        assertEquals(directory.size(), size);
    }

    private Directory getDirectoryByNS(final CompoundDirectory compound, final String namespace) {
        for (int i = 0; i < compound.directoryCount(); i++) {
            Directory candidate = compound.getDirectory(i);

            Iterator<Entry> entries = candidate.iterator();
            if (entries.hasNext()) {
                Entry entry = entries.next();
                if (entry.getIdentifier() instanceof String && ((String) entry.getIdentifier()).startsWith(namespace)) {
                    return candidate;
                }
            }
        }

        return null;
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentPhotoshop() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // photoshop|http://ns.adobe.com/photoshop/1.0/
        Directory photoshop = getDirectoryByNS(compound, XMP.NS_PHOTOSHOP);

        assertNotNull(photoshop);
        assertEquals(3, photoshop.size());
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/ColorMode"), hasValue("1"));
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/ICCProfile"), hasValue("Dot Gain 20%"));
        assertThat(photoshop.getEntryById("http://ns.adobe.com/photoshop/1.0/History"), hasValue(""));
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentMM() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // xapMM|http://ns.adobe.com/xap/1.0/mm/
        Directory mm = getDirectoryByNS(compound, XMP.NS_XAP_MM);

        assertNotNull(mm);
        assertEquals(3, mm.size());
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DocumentID"), hasValue("uuid:6DCA50CC7D53DD119F20F5A7EA4C9BEC"));
        assertThat(directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/InstanceID"), hasValue("uuid:6ECA50CC7D53DD119F20F5A7EA4C9BEC"));

        // Custom test, as NamedNodeMap does not preserve order (tests can't rely on XML impl specifics)
        Entry derivedFrom = directory.getEntryById("http://ns.adobe.com/xap/1.0/mm/DerivedFrom");
        assertNotNull(derivedFrom);
        assertThat(derivedFrom.getValue(), instanceOf(RDFDescription.class));

        // stRef|http://ns.adobe.com/xap/1.0/sType/ResourceRef#
        RDFDescription stRef = (RDFDescription) derivedFrom.getValue();
        assertThat(stRef.getEntryById("http://ns.adobe.com/xap/1.0/sType/ResourceRef#instanceID"), hasValue("uuid:74E1C905B405DD119306A1902BA5AA28"));
        assertThat(stRef.getEntryById("http://ns.adobe.com/xap/1.0/sType/ResourceRef#documentID"), hasValue("uuid:7A6C79768005DD119306A1902BA5AA28"));
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentDC() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // dc|http://purl.org/dc/elements/1.1/
        Directory dc = getDirectoryByNS(compound, XMP.NS_DC);

        assertNotNull(dc);
        assertEquals(1, dc.size());

        assertThat(dc.getEntryById("http://purl.org/dc/elements/1.1/format"), hasValue("image/jpeg"));
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentTIFF() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // tiff|http://ns.adobe.com/tiff/1.0/
        Directory tiff = getDirectoryByNS(compound, XMP.NS_TIFF);

        assertNotNull(tiff);
        assertEquals(5, tiff.size());
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/Orientation"), hasValue("1"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/XResolution"), hasValue("720000/10000"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/YResolution"), hasValue("720000/10000"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/ResolutionUnit"), hasValue("2"));
        assertThat(tiff.getEntryById("http://ns.adobe.com/tiff/1.0/NativeDigest"), hasValue("256,257,258,259,262,274,277,284,530,531,282,283,296,301,318,319,529,532,306,270,271,272,305,315,33432;C08D8E93274C4BEE83E86CF999955A87"));
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentXAP() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // xap|http://ns.adobe.com/xap/1.0/
        Directory xap = getDirectoryByNS(compound, XMP.NS_XAP);

        assertNotNull(xap);
        assertEquals(4, xap.size());
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/ModifyDate"), hasValue("2008-07-16T14:44:49-07:00"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/CreatorTool"), hasValue("Adobe Photoshop CS3 Windows"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/CreateDate"), hasValue("2008-07-16T14:44:49-07:00"));
        assertThat(xap.getEntryById("http://ns.adobe.com/xap/1.0/MetadataDate"), hasValue("2008-07-16T14:44:49-07:00"));
    }

    @Test
    public void testRDFAttributeSyntaxCompoundDirectoryContentEXIF() throws IOException {
        Directory directory = createReader().read(getResourceAsIIS("/xmp/rdf-attribute-shorthand.xml"));

        assertThat(directory, instanceOf(CompoundDirectory.class));
        CompoundDirectory compound = (CompoundDirectory) directory;

        // exif|http://ns.adobe.com/exif/1.0/
        Directory exif = getDirectoryByNS(compound, XMP.NS_EXIF);

        assertNotNull(exif);
        assertEquals(4, exif.size());
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/ColorSpace"), hasValue("-1")); // SIC. Same as unsigned short 65535, meaning "uncalibrated"?
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/PixelXDimension"), hasValue("426"));
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/PixelYDimension"), hasValue("550"));
        assertThat(exif.getEntryById("http://ns.adobe.com/exif/1.0/NativeDigest"), hasValue("36864,40960,40961,37121,37122,40962,40963,37510,40964,36867,36868,33434,33437,34850,34852,34855,34856,37377,37378,37379,37380,37381,37382,37383,37384,37385,37386,37396,41483,41484,41486,41487,41488,41492,41493,41495,41728,41729,41730,41985,41986,41987,41988,41989,41990,41991,41992,41993,41994,41995,41996,42016,0,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20,22,23,24,25,26,27,28,30;A7F21D25E2C562F152B2C4ECC9E534DA"));
    }

    @Test
    public void testNoExternalRequest() throws Exception {
        assertTimeoutPreemptively(Duration.ofMillis(2500L), () -> {
            String maliciousXML = resourceAsString("/xmp/xmp-jpeg-xxe.xml");

            try (HTTPServer server = new HTTPServer()) {
                String dynamicXML = maliciousXML.replace("http://localhost:7777/", "http://localhost:" + server.port() + "/");

                try (DirectImageInputStream input = new DirectImageInputStream(new ByteArrayInputStream(dynamicXML.getBytes(StandardCharsets.UTF_8)));) {
                    createReader().read(input);
                } catch (IOException ioe) {
                    if (ioe.getMessage().contains("501")) {
                        throw new AssertionError("Reading should not cause external requests", ioe);
                    }

                    // Any other exception is a bug (but might happen if the parser does not support certain features)
                    throw ioe;
                }
            }
        });
    }

    private String resourceAsString(String name) throws IOException {
        StringBuilder builder = new StringBuilder(1024);

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(getResource(name).openStream(), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                builder.append(line)
                        .append('\n');
            }
        }

        return builder.toString();
    }

    private static class HTTPServer implements AutoCloseable {
        private final ServerSocket server;
        private final Thread thread;

        HTTPServer() throws IOException {
            server = new ServerSocket(0, 1);
            thread = new Thread(new Runnable() {
                @Override public void run() {
                    serve();
                }
            });
            thread.start();
        }

        public final int port() {
            return server.getLocalPort();
        }

        private void serve() {
            try {
                Socket client = server.accept();

                // Get the input stream, don't care about the request
                try (InputStream inputStream = client.getInputStream()) {
                    while (inputStream.available() > 0) {
                        if (inputStream.read() == -1) {
                            break;
                        }
                    }

                    // Answer with 501, this will cause the client to throw IOException
                    try (OutputStream outputStream = client.getOutputStream()) {
                        outputStream.write("HTTP/1.0 501 Not Implemented\r\n\r\n".getBytes(StandardCharsets.UTF_8));
                    }
                }
            }
            catch (IOException e) {
                if (server.isClosed() && e instanceof SocketException) {
                    // Socket closed due to server close, all good
                    return;
                }

                throw new RuntimeException(e);
            }
        }

        @Override public void close() throws Exception {
            server.close();
            thread.join(); // It's advised against throwing InterruptedException here, but this is not production code...
        }
    }
}