PSDImageReaderTest.java

/*
 * Copyright (c) 2014, 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.plugins.psd;

import com.twelvemonkeys.imageio.util.ImageReaderAbstractTest;
import com.twelvemonkeys.imageio.util.ProgressListenerBase;

import org.w3c.dom.NodeList;

import javax.imageio.ImageIO;
import javax.imageio.ImageReadParam;
import javax.imageio.ImageReader;
import javax.imageio.ImageTypeSpecifier;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataNode;
import javax.imageio.spi.ImageReaderSpi;
import javax.imageio.stream.ImageInputStream;
import java.awt.*;
import java.awt.color.*;
import java.awt.image.*;
import java.io.EOFException;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

/**
 * PSDImageReaderTest
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @author last modified by $Author: haraldk$
 * @version $Id: PSDImageReaderTest.java,v 1.0 Apr 1, 2008 10:39:17 PM haraldk Exp$
 */
public class PSDImageReaderTest extends ImageReaderAbstractTest<PSDImageReader> {
    @Override
    protected ImageReaderSpi createProvider() {
        return new PSDImageReaderSpi();
    }

    @Override
    protected List<TestData> getTestData() {
        return Arrays.asList(
                // 5 channel, RGB
                new TestData(getClassLoaderResource("/psd/photoshopping.psd"), new Dimension(300, 225)),
                // 1 channel, gray, 8 bit samples
                new TestData(getClassLoaderResource("/psd/buttons.psd"), new Dimension(20, 20)),
                // 5 channel, CMYK
                new TestData(getClassLoaderResource("/psd/escenic-liquid-logo.psd"), new Dimension(595, 420)),
                // 3 channel RGB, "no composite layer"
                new TestData(getClassLoaderResource("/psd/jugware-icon.psd"), new Dimension(128, 128)),
                // 3 channel RGB, old data, no layer info/mask
                new TestData(getClassLoaderResource("/psd/MARBLES.PSD"), new Dimension(1419, 1001)),
                // 1 channel, indexed color
                new TestData(getClassLoaderResource("/psd/coral_fish.psd"), new Dimension(800, 800)),
                // 1 channel, bitmap, 1 bit samples
                new TestData(getClassLoaderResource("/psd/test_bitmap.psd"), new Dimension(710, 512)),
                // 1 channel, gray, 16 bit samples
                new TestData(getClassLoaderResource("/psd/test_gray16.psd"), new Dimension(710, 512)),
                // 4 channel, CMYK, 16 bit samples
                new TestData(getClassLoaderResource("/psd/cmyk_16bits.psd"), new Dimension(1000, 275)),
                // 3 channel, RGB, 8 bit samples ("Large Document Format" aka PSB)
                new TestData(getClassLoaderResource("/psb/test_original.psb"), new Dimension(710, 512)),
                // From http://telegraphics.com.au/svn/psdparse/trunk/psd/
                new TestData(getClassLoaderResource("/psd/adobehq.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq_ind.psd"), new Dimension(341, 512)),
                // Contains a shorter than normal PrintFlags chunk
                new TestData(getClassLoaderResource("/psd/adobehq-2.5.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-3.0.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-5.5.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-7.0.psd"), new Dimension(341, 512)),
                // From https://github.com/kmike/psd-tools/tree/master/tests/psd_files
                new TestData(getClassLoaderResource("/psd/masks2.psd"), new Dimension(640, 1136)),
                // RGB, multiple alpha channels, no transparency
                new TestData(getClassLoaderResource("/psd/rgb-multichannel-no-transparency.psd"), new Dimension(100, 100)),
                new TestData(getClassLoaderResource("/psb/rgb-multichannel-no-transparency.psb"), new Dimension(100, 100)),
                // CMYK, uncompressed + contains some uncommon MeSa (instead of 8BIM) resource blocks
                new TestData(getClassLoaderResource("/psd/fruit-cmyk-MeSa-resource.psd"), new Dimension(400, 191)),
                // 3 channel, RGB, 32 bit samples
                new TestData(getClassLoaderResource("/psd/32bit5x5.psd"), new Dimension(5, 5)),
                // 3 channel, RGB, written by GIMP, compressed with PackBits runs longer than the row length
                new TestData(getClassLoaderResource("/psd/gimp-32x32-packbits-overflow.psd"), new Dimension(32, 32))
                // TODO: Need more recent ZIP compressed PSD files from CS2/CS3+
        );
    }

    @Override
    protected List<TestData> getTestDataForAffineTransformOpCompatibility() {
        return Arrays.asList(
                // 5 channel, RGB
                new TestData(getClassLoaderResource("/psd/photoshopping.psd"), new Dimension(300, 225)),
                // 1 channel, gray, 8 bit samples
                new TestData(getClassLoaderResource("/psd/buttons.psd"), new Dimension(20, 20)),
                // 3 channel RGB, "no composite layer"
                new TestData(getClassLoaderResource("/psd/jugware-icon.psd"), new Dimension(128, 128)),
                // 3 channel RGB, old data, no layer info/mask
                new TestData(getClassLoaderResource("/psd/MARBLES.PSD"), new Dimension(1419, 1001)),
                // 1 channel, indexed color
                new TestData(getClassLoaderResource("/psd/coral_fish.psd"), new Dimension(800, 800)),
                // 1 channel, bitmap, 1 bit samples
                new TestData(getClassLoaderResource("/psd/test_bitmap.psd"), new Dimension(710, 512)),
                // 1 channel, gray, 16 bit samples
                new TestData(getClassLoaderResource("/psd/test_gray16.psd"), new Dimension(710, 512)),
                // 3 channel, RGB, 8 bit samples ("Large Document Format" aka PSB)
                new TestData(getClassLoaderResource("/psb/test_original.psb"), new Dimension(710, 512)),
                // From http://telegraphics.com.au/svn/psdparse/trunk/psd/
                new TestData(getClassLoaderResource("/psd/adobehq.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq_ind.psd"), new Dimension(341, 512)),
                // Contains a shorter than normal PrintFlags chunk
                new TestData(getClassLoaderResource("/psd/adobehq-2.5.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-3.0.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-5.5.psd"), new Dimension(341, 512)),
                new TestData(getClassLoaderResource("/psd/adobehq-7.0.psd"), new Dimension(341, 512)),
                // From https://github.com/kmike/psd-tools/tree/master/tests/psd_files
                new TestData(getClassLoaderResource("/psd/masks2.psd"), new Dimension(640, 1136)),
                // RGB, multiple alpha channels, no transparency
                new TestData(getClassLoaderResource("/psd/rgb-multichannel-no-transparency.psd"), new Dimension(100, 100)),
                new TestData(getClassLoaderResource("/psb/rgb-multichannel-no-transparency.psb"), new Dimension(100, 100))
        );
    }

    @Override
    protected List<String> getFormatNames() {
        return Collections.singletonList("psd");
    }

    @Override
    protected List<String> getSuffixes() {
        return Collections.singletonList("psd");
    }

    @Override
    protected List<String> getMIMETypes() {
        return Arrays.asList(
                "image/vnd.adobe.photoshop",
                "application/vnd.adobe.photoshop",
                "image/x-psd"
        );
    }

    @Test
    public void testSupportsThumbnail() throws IOException {
        PSDImageReader imageReader = createReader();
        assertTrue(imageReader.readerSupportsThumbnails());
    }

    @Test
    public void testThumbnailReading() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(0).getInputStream()) {
            imageReader.setInput(stream);

            assertEquals(1, imageReader.getNumThumbnails(0));

            BufferedImage thumbnail = imageReader.readThumbnail(0, 0);
            assertNotNull(thumbnail);

            assertEquals(128, thumbnail.getWidth());
            assertEquals(96, thumbnail.getHeight());
        }
    }

    @Test
    public void testThumbnailReadingNoInput() throws IOException {
        PSDImageReader imageReader = createReader();

        try {
            imageReader.getNumThumbnails(0);
            fail("Expected IllegalStateException");
        }
        catch (IllegalStateException expected) {
            assertTrue(expected.getMessage().toLowerCase().contains("input"));
        }

        try {
            imageReader.getThumbnailWidth(0, 0);
            fail("Expected IllegalStateException");
        }
        catch (IllegalStateException expected) {
            assertTrue(expected.getMessage().toLowerCase().contains("input"));
        }

        try {
            imageReader.getThumbnailHeight(0, 0);
            fail("Expected IllegalStateException");
        }
        catch (IllegalStateException expected) {
            assertTrue(expected.getMessage().toLowerCase().contains("input"));
        }

        try {
            imageReader.readThumbnail(0, 0);
            fail("Expected IllegalStateException");
        }
        catch (IllegalStateException expected) {
            assertTrue(expected.getMessage().toLowerCase().contains("input"));
        }
    }

    @Test
    public void testThumbnailReadingOutOfBounds() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(0).getInputStream()) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);

            try {
                imageReader.getNumThumbnails(numImages + 1);
                fail("Expected IndexOutOfBoundsException");
            }
            catch (IndexOutOfBoundsException expected) {
                assertTrue(expected.getMessage().toLowerCase().contains("index"), expected.getMessage());
            }

            try {
                imageReader.getThumbnailWidth(-1, 0);
                fail("Expected IndexOutOfBoundsException");
            }
            catch (IndexOutOfBoundsException expected) {
                assertTrue(expected.getMessage().toLowerCase().contains("index"), expected.getMessage());
            }

            try {
                imageReader.getThumbnailHeight(0, -2);
                fail("Expected IndexOutOfBoundsException");
            }
            catch (IndexOutOfBoundsException expected) {
                // Sloppy...
                assertTrue(expected.getMessage().toLowerCase().contains("-2"), expected.getMessage());
            }

            try {
                imageReader.readThumbnail(numImages + 99, 42);
                fail("Expected IndexOutOfBoundsException");
            }
            catch (IndexOutOfBoundsException expected) {
                assertTrue(expected.getMessage().toLowerCase().contains("index"), expected.getMessage());
            }
        }
    }

    @Test
    public void testThumbnailDimensions() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(0).getInputStream()) {
            imageReader.setInput(stream);

            assertEquals(1, imageReader.getNumThumbnails(0));

            assertEquals(128, imageReader.getThumbnailWidth(0, 0));
            assertEquals(96, imageReader.getThumbnailHeight(0, 0));
        }
    }

    @Test
    public void testThumbnailReadListeners() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(0).getInputStream()) {
            imageReader.setInput(stream);

            final List<Object> seqeunce = new ArrayList<>();
            imageReader.addIIOReadProgressListener(new ProgressListenerBase() {
                private float lastPercentageDone = 0;

                @Override
                public void thumbnailStarted(final ImageReader pSource, final int pImageIndex, final int pThumbnailIndex) {
                    seqeunce.add("started");
                }

                @Override
                public void thumbnailComplete(final ImageReader pSource) {
                    seqeunce.add("complete");
                }

                @Override
                public void thumbnailProgress(final ImageReader pSource, final float pPercentageDone) {
                    // Optional
                    assertEquals(1, seqeunce.size(), "Listener invoked out of sequence");
                    assertTrue(pPercentageDone >= lastPercentageDone);
                    lastPercentageDone = pPercentageDone;
                }
            });

            BufferedImage thumbnail = imageReader.readThumbnail(0, 0);
            assertNotNull(thumbnail);

            assertEquals(2, seqeunce.size(), "Listeners not invoked");
            assertEquals("started", seqeunce.get(0));
            assertEquals("complete", seqeunce.get(1));
        }
    }

    @Test
    public void testReadLayers() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(3).getInputStream()) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);

            assertEquals(3, numImages);

            for (int i = 0; i < numImages; i++) {
                BufferedImage image = imageReader.read(i);
                assertNotNull(image);

                // Make sure layers are correct size
                assertEquals(image.getWidth(), imageReader.getWidth(i));
                assertEquals(image.getHeight(), imageReader.getHeight(i));
            }
        }
    }

    @Test
    public void testImageTypesLayers() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(3).getInputStream()) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);
            for (int i = 0; i < numImages; i++) {
                ImageTypeSpecifier rawType = imageReader.getRawImageType(i);
                assertNotNull(rawType);

                Iterator<ImageTypeSpecifier> types = imageReader.getImageTypes(i);

                assertNotNull(types);
                assertTrue(types.hasNext());

                boolean found = false;

                while (types.hasNext()) {
                    ImageTypeSpecifier type = types.next();

                    if (!found && (rawType == type || rawType.equals(type))) {
                        found = true;
                    }
                }

                assertTrue(found, "RAW image type not in type iterator");
            }
        }
    }

    @Test
    public void testReadLayersExplicitType() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(3).getInputStream()) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);
            for (int i = 0; i < numImages; i++) {
                Iterator<ImageTypeSpecifier> types = imageReader.getImageTypes(i);

                while (types.hasNext()) {
                    ImageTypeSpecifier type = types.next();
                    ImageReadParam param = imageReader.getDefaultReadParam();
                    param.setDestinationType(type);
                    BufferedImage image = imageReader.read(i, param);

                    assertEquals(type.getBufferedImageType(), image.getType());

                    if (type.getBufferedImageType() == 0) {
                        // TODO: If type.getBIT == 0, test more
                        // Compatible color model
                        assertEquals(type.getNumComponents(), image.getColorModel().getNumComponents());

                        // Same color space
                        assertEquals(type.getColorModel().getColorSpace(), image.getColorModel().getColorSpace());

                        // Same number of samples
                        assertEquals(type.getNumBands(), image.getSampleModel().getNumBands());

                        // Same number of bits/sample
                        for (int j = 0; j < type.getNumBands(); j++) {
                            assertEquals(type.getBitsPerBand(j), image.getSampleModel().getSampleSize(j));
                        }
                    }
                }
            }
        }
    }

    @Test
    public void testReadLayersExplicitDestination() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = getTestData().get(3).getInputStream()) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);
            for (int i = 0; i < numImages; i++) {
                Iterator<ImageTypeSpecifier> types = imageReader.getImageTypes(i);
                int width = imageReader.getWidth(i);
                int height = imageReader.getHeight(i);

                while (types.hasNext()) {
                    ImageTypeSpecifier type = types.next();
                    ImageReadParam param = imageReader.getDefaultReadParam();
                    BufferedImage destination = type.createBufferedImage(width, height);
                    param.setDestination(destination);

                    BufferedImage image = imageReader.read(i, param);

                    assertSame(destination, image);
                }
            }
        }
    }

    @Test
    public void testGrayAlphaLayers() throws IOException {
        PSDImageReader imageReader = createReader();

        // The expected colors for each layer
        int[] colors = new int[] {
                -1, // Don't care
                0xff000000,
                0xffffffff,
                0xff737373,
                0xff3c3c3c,
                0xff656565,
                0xffc9c9c9,
                0xff979797,
                0xff5a5a5a
        };

        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/test_grayscale_boxes.psd"))) {
            imageReader.setInput(stream);

            int numImages = imageReader.getNumImages(true);
            assertEquals(colors.length, numImages);

            // Skip reading the merged composite image
            for (int i = 1; i < numImages; i++) {
                Iterator<ImageTypeSpecifier> types = imageReader.getImageTypes(i);
                int width = imageReader.getWidth(i);
                int height = imageReader.getHeight(i);

                while (types.hasNext()) {
                    ImageTypeSpecifier type = types.next();

                    ImageReadParam param = imageReader.getDefaultReadParam();
                    BufferedImage destination = type.createBufferedImage(width, height);
                    param.setDestination(destination);

                    BufferedImage image = imageReader.read(i, param);

                    assertSame(destination, image);

                    // NOTE: Allow some slack, as Java 1.7 and 1.8 color management differs slightly
                    int rgb = image.getRGB(0, 0);
                    assertRGBEquals("Colors differ", colors[i], rgb, 1);
                }
            }
        }
    }

    @Test
    public void testMultiChannelNoTransparencyPSD() throws IOException {
        PSDImageReader imageReader = createReader();

        // The following PSD is RGB, has 4 channels (1 alpha/auxillary channel), but should be treated as opaque
        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/rgb-multichannel-no-transparency.psd"))) {
            imageReader.setInput(stream);

            BufferedImage image = imageReader.read(0);

            assertEquals(Transparency.OPAQUE, image.getTransparency());
        }
    }

    @Test
    public void testMultiChannelNoTransparencyPSB() throws IOException {
        PSDImageReader imageReader = createReader();

        // The following PSB is RGB, has 4 channels (1 alpha/auxiliary channel), but should be treated as opaque
        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psb/rgb-multichannel-no-transparency.psb"))) {
            imageReader.setInput(stream);

            BufferedImage image = imageReader.read(0);

            assertEquals(Transparency.OPAQUE, image.getTransparency());
        }
    }

    @Test
    public void testReadUnicodeLayerName() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/long-layer-names.psd"))) {
            imageReader.setInput(stream);

            IIOMetadata metadata = imageReader.getImageMetadata(0);
            IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(PSDMetadata.NATIVE_METADATA_FORMAT_NAME);
            NodeList layerInfo = root.getElementsByTagName("LayerInfo");

            assertEquals(1, layerInfo.getLength()); // Sanity
            assertEquals("If_The_Layer_Name_Is_Really_Long_Oh_No_What_Do_I_Do", ((IIOMetadataNode) layerInfo.item(0)).getAttribute("name"));
        }
    }

    @Test
    public void testGroupLayerRead() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/layer_group_32bit5x5.psd"))) {
            imageReader.setInput(stream);

            IIOMetadata metadata = imageReader.getImageMetadata(0);
            List<PSDLayerInfo> layerInfos = ((PSDMetadata) metadata).layerInfo;

            assertEquals(layerInfos.size(), 8);

            // Normal layer, top level
            PSDLayerInfo layer5 = layerInfos.get(0);
            assertNotNull(layer5);
            assertEquals("Layer 5", layer5.getLayerName());
            assertEquals(2, layer5.getLayerId());
            assertEquals(-1, layer5.groupId);
            assertFalse(layer5.isGroup);
            assertFalse(layer5.isDivider);

            // Divider, invisible in UI, in "group 1" (group id 6)
            PSDLayerInfo sectionDivider1 = layerInfos.get(1);
            assertNotNull(sectionDivider1);
            assertEquals("</Layer group>", sectionDivider1.getLayerName());
            assertEquals(7, sectionDivider1.getLayerId());
            assertEquals(6, sectionDivider1.groupId); // ...or -1?
            assertFalse(sectionDivider1.isGroup);
            assertTrue(sectionDivider1.isDivider);

            // Normal layer, in "group 1" (group id 6)
            PSDLayerInfo layer2 = layerInfos.get(2);
            assertNotNull(layer2);
            assertEquals("Layer 2", layer2.getLayerName());
            assertEquals(5, layer2.getLayerId());
            assertEquals(6, layer2.groupId);
            assertFalse(layer2.isGroup);
            assertFalse(layer2.isDivider);

            // Divider, invisible in UI, in "group 1" (group id 9)
            PSDLayerInfo sectionDivider2 = layerInfos.get(3);
            assertNotNull(sectionDivider2);
            assertEquals("</Layer group>", sectionDivider2.getLayerName());
            assertEquals(10, sectionDivider2.getLayerId());
            assertEquals(9, sectionDivider2.groupId); // ...or 6?
            assertFalse(sectionDivider2.isGroup);
            assertTrue(sectionDivider2.isDivider);

            // Normal layer, in "nested group 1" (group id 9)
            PSDLayerInfo groupedLayer = layerInfos.get(4);
            assertNotNull(groupedLayer);
            assertEquals("Nested Group Layer 1", groupedLayer.getLayerName());
            assertEquals(8, groupedLayer.getLayerId());
            assertEquals(9, groupedLayer.groupId);
            assertFalse(groupedLayer.isGroup);
            assertFalse(groupedLayer.isDivider);

            // Group layer, in "group 1" (group id 6)
            PSDLayerInfo nestedGroupLayer = layerInfos.get(5);
            assertNotNull(nestedGroupLayer);
            assertEquals("nested group 1", nestedGroupLayer.getLayerName());
            assertEquals(9, nestedGroupLayer.getLayerId());
            assertEquals(6, nestedGroupLayer.groupId);
            assertTrue(nestedGroupLayer.isGroup);
            assertFalse(nestedGroupLayer.isDivider);

            // Group layer, top level
            PSDLayerInfo groupLayer = layerInfos.get(6);
            assertNotNull(groupLayer);
            assertEquals("group 1", groupLayer.getLayerName());
            assertEquals(6, groupLayer.getLayerId());
            assertEquals(-1, groupLayer.groupId);
            assertTrue(groupLayer.isGroup);
            assertFalse(groupLayer.isDivider);

            // Normal layer, top level
            PSDLayerInfo layer1 = layerInfos.get(7);
            assertNotNull(layer1);
            assertEquals("Layer 1", layer1.getLayerName());
            assertEquals(4, layer1.getLayerId());
            assertEquals(-1, layer1.groupId);
            assertFalse(layer1.isGroup);
            assertFalse(layer1.isDivider);
        }
    }

    @Test
    public void test16bitLr16AndZIPPredictor() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/fruit-cmyk-MeSa-resource.psd"))) {
            imageReader.setInput(stream);

            assertEquals(5, imageReader.getNumImages(true));

            assertEquals(400, imageReader.getWidth(2));
            assertEquals(191, imageReader.getHeight(2));

            BufferedImage layer2 = imageReader.read(2);// Read the 16 bit ZIP Predictor based layer
            assertNotNull(layer2);
            assertEquals(400, layer2.getWidth());
            assertEquals(191, layer2.getHeight());
            assertEquals(ColorSpace.TYPE_CMYK, layer2.getColorModel().getColorSpace().getType());
            assertEquals(5, layer2.getColorModel().getNumComponents());

            // For cross-platform testing: as the PSD does not have embedded CMYK profile, we'll use built-in RGB conversion
            ColorModel cmykAlpha = new ComponentColorModel(new FakeCMYKColorSpace(), true, false, Transparency.TRANSLUCENT, DataBuffer.TYPE_USHORT);
            layer2 = new BufferedImage(cmykAlpha, layer2.getRaster(), cmykAlpha.isAlphaPremultiplied(), null);

            assertRGBEquals("RGB differ at (0,0)", 0xff060808, layer2.getRGB(0, 0), 4);
            assertRGBEquals("RGB differ at (399,0)", 0xff060808, layer2.getRGB(399, 0), 4);
            assertRGBEquals("RGB differ at (200,95)", 0x00ffffff, layer2.getRGB(200, 95), 4); // Transparent
            assertRGBEquals("RGB differ at (0,191)", 0xff060808, layer2.getRGB(0, 190), 4);
            assertRGBEquals("RGB differ at (399,191)", 0xff060808, layer2.getRGB(399, 190), 4);

            assertEquals(400, imageReader.getWidth(3));
            assertEquals(191, imageReader.getHeight(3));

            BufferedImage layer3 = imageReader.read(3);// Read the 16 bit ZIP Predictor based layer
            assertNotNull(layer3);
            assertEquals(400, layer3.getWidth());
            assertEquals(191, layer3.getHeight());
            assertEquals(ColorSpace.TYPE_CMYK, layer3.getColorModel().getColorSpace().getType());
            assertEquals(5, layer3.getColorModel().getNumComponents());

            // For cross-platform testing: as the PSD does not have embedded CMYK profile, we'll use built-in RGB conversion
            layer3 = new BufferedImage(cmykAlpha, layer3.getRaster(), cmykAlpha.isAlphaPremultiplied(), null);

            assertRGBEquals("RGB differ at (0,0)", 0xfff5cb0c, layer3.getRGB(0, 0), 4);
            assertRGBEquals("RGB differ at (399,0)", 0xfff5cb0c, layer3.getRGB(399, 0), 4);
            assertRGBEquals("RGB differ at (200,95)", 0xffff152a, layer3.getRGB(200, 95), 4); // Red
            assertRGBEquals("RGB differ at (0,191)", 0xfff5cb0c, layer3.getRGB(0, 190), 4);
            assertRGBEquals("RGB differ at (399,191)", 0xfff5cb0c, layer3.getRGB(399, 190), 4);
        }
    }

    @Test
    public void test32bitLr32AndZIPPredictor() throws IOException {
        PSDImageReader imageReader = createReader();

        try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/psd/32bit5x5.psd"))) {
            imageReader.setInput(stream);

            assertEquals(4, imageReader.getNumImages(true));

            assertEquals(5, imageReader.getWidth(1));
            assertEquals(5, imageReader.getHeight(1));

            BufferedImage image = imageReader.read(1);// Read the 32 bit ZIP Predictor based layer
            assertNotNull(image);
            assertEquals(5, image.getWidth());
            assertEquals(5, image.getHeight());

            assertRGBEquals("RGB differ at (0,0)", 0xff888888, image.getRGB(0, 0), 4);
            assertRGBEquals("RGB differ at (4,4)", 0xff888888, image.getRGB(4, 4), 4);
        }
    }

    @Test
    public void testBrokenPackBitsThrowsEOFException() throws IOException {
        assertTimeoutPreemptively(Duration.ofMillis(1000), () -> {
            PSDImageReader imageReader = createReader();

            try (ImageInputStream stream = ImageIO.createImageInputStream(getClassLoaderResource("/broken-psd/short-packbits.psd"))) {
                imageReader.setInput(stream);

                assertEquals(1, imageReader.getNumImages(true));

                assertEquals(427, imageReader.getWidth(0));
                assertEquals(107, imageReader.getHeight(0));

                try {
                    imageReader.read(0);

                    fail("Expected EOFException, is the test broken?");
                } catch (EOFException expected) {
                    assertTrue(expected.getMessage().contains("PackBits"));
                }
            }
        });
    }


    final static class FakeCMYKColorSpace extends ColorSpace {
        FakeCMYKColorSpace() {
            super(ColorSpace.TYPE_CMYK, 4);
        }

        public float[] toRGB(float[] cmyk) {
            return new float[] {
                    (1 - cmyk[0]) * (1 - cmyk[3]),
                    (1 - cmyk[1]) * (1 - cmyk[3]),
                    (1 - cmyk[2]) * (1 - cmyk[3])
            };
        }

        public float[] fromRGB(float[] rgb) {
            throw new UnsupportedOperationException();
        }

        public float[] toCIEXYZ(float[] cmyk) {
            throw new UnsupportedOperationException();
        }

        public float[] fromCIEXYZ(float[] cieXYZ) {
            throw new UnsupportedOperationException();
        }
    }
}