Major ImageMetadata refactor for more consistent standard metadata support.

Fixes a few related bugs as a bonus.
This commit is contained in:
Harald Kuhr
2022-10-08 13:43:26 +02:00
parent 9375bfda9a
commit 6458fcdcbd
37 changed files with 1648 additions and 1952 deletions
@@ -1,13 +1,13 @@
package com.twelvemonkeys.imageio.plugins.iff;
import javax.imageio.IIOException;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.IndexColorModel;
import java.awt.image.*;
import java.util.ArrayList;
import java.util.List;
import static com.twelvemonkeys.imageio.plugins.iff.IFF.*;
import static com.twelvemonkeys.imageio.plugins.iff.IFFUtil.toChunkStr;
import static com.twelvemonkeys.lang.Validate.isTrue;
/**
* Form.
@@ -27,7 +27,7 @@ abstract class Form {
abstract int width();
abstract int height();
abstract float aspect();
abstract double aspect();
abstract int bitplanes();
abstract int compressionType();
@@ -118,7 +118,7 @@ abstract class Form {
}
private ILBMForm(final int formType, final BMHDChunk bitmapHeader, final CAMGChunk viewMode, final CMAPChunk colorMap, final AbstractMultiPaletteChunk multiPalette, final XS24Chunk thumbnail, final BODYChunk body) {
super(formType);
super(isTrue(validFormType(formType), formType, "Unknown IFF Form type: %s"));
this.bitmapHeader = bitmapHeader;
this.viewMode = viewMode;
this.colorMap = colorMap;
@@ -127,6 +127,19 @@ abstract class Form {
this.body = body;
}
private static boolean validFormType(int formType) {
switch (formType) {
case TYPE_ACBM:
case TYPE_ILBM:
case TYPE_PBM:
case TYPE_RGB8:
case TYPE_RGBN:
return true;
default:
return false;
}
}
@Override
int width() {
return bitmapHeader.width;
@@ -148,8 +161,8 @@ abstract class Form {
}
@Override
float aspect() {
return bitmapHeader.yAspect == 0 ? 0 : (bitmapHeader.xAspect / (float) bitmapHeader.yAspect);
double aspect() {
return bitmapHeader.yAspect == 0 ? 0 : (bitmapHeader.xAspect / (double) bitmapHeader.yAspect);
}
@Override
@@ -297,7 +310,7 @@ abstract class Form {
}
private DEEPForm(final int formType, final DGBLChunk deepGlobal, final DLOCChunk deepLocation, final DPELChunk deepPixel, final XS24Chunk thumbnail, final BODYChunk body) {
super(formType);
super(isTrue(validFormType(formType), formType, "Unknown IFF Form type: %s"));
this.deepGlobal = deepGlobal;
this.deepLocation = deepLocation;
this.deepPixel = deepPixel;
@@ -305,6 +318,15 @@ abstract class Form {
this.body = body;
}
private static boolean validFormType(int formType) {
switch (formType) {
case TYPE_DEEP:
case TYPE_TVPP:
return true;
default:
return false;
}
}
@Override
int width() {
@@ -337,8 +359,8 @@ abstract class Form {
}
@Override
float aspect() {
return deepGlobal.yAspect == 0 ? 0 : deepGlobal.xAspect / (float) deepGlobal.yAspect;
double aspect() {
return deepGlobal.yAspect == 0 ? 0 : deepGlobal.xAspect / (double) deepGlobal.yAspect;
}
@Override
@@ -1,188 +1,85 @@
/*
* Copyright (c) 2020, 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.iff;
import com.twelvemonkeys.imageio.AbstractMetadata;
import com.twelvemonkeys.imageio.StandardImageMetadataSupport;
import javax.imageio.metadata.IIOMetadataNode;
import java.awt.*;
import java.awt.image.IndexColorModel;
import javax.imageio.ImageTypeSpecifier;
import java.awt.image.*;
import java.nio.charset.StandardCharsets;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static com.twelvemonkeys.imageio.plugins.iff.IFF.*;
import static com.twelvemonkeys.lang.Validate.isTrue;
import static com.twelvemonkeys.imageio.plugins.iff.IFFUtil.toChunkStr;
import static com.twelvemonkeys.lang.Validate.notNull;
import static java.util.Collections.emptyList;
final class IFFImageMetadata extends AbstractMetadata {
private final Form header;
private final IndexColorModel colorMap;
private final List<GenericChunk> meta;
IFFImageMetadata(Form header, IndexColorModel colorMap) {
this.header = notNull(header, "header");
isTrue(validFormType(header.formType), header.formType, "Unknown IFF Form type: %s");
this.colorMap = colorMap;
this.meta = header.meta;
final class IFFImageMetadata extends StandardImageMetadataSupport {
IFFImageMetadata(ImageTypeSpecifier type, Form header, IndexColorModel palette) {
this(builder(type), notNull(header, "header"), palette);
}
private boolean validFormType(int formType) {
switch (formType) {
default:
return false;
case TYPE_ACBM:
case TYPE_DEEP:
case TYPE_ILBM:
case TYPE_PBM:
case TYPE_RGB8:
case TYPE_RGBN:
case TYPE_TVPP:
return true;
}
private IFFImageMetadata(Builder builder, Form header, IndexColorModel palette) {
super(builder.withPalette(palette)
.withCompressionName(compressionName(header))
.withBitsPerSample(bitsPerSample(header))
.withPlanarConfiguration(planarConfiguration(header))
.withPixelAspectRatio(header.aspect() != 0 ? header.aspect() : null)
.withFormatVersion("1.0")
.withTextEntries(textEntries(header)));
}
@Override
protected IIOMetadataNode getStandardChromaNode() {
IIOMetadataNode chroma = new IIOMetadataNode("Chroma");
IIOMetadataNode csType = new IIOMetadataNode("ColorSpaceType");
chroma.appendChild(csType);
switch (header.bitplanes()) {
case 8:
if (colorMap == null) {
csType.setAttribute("name", "GRAY");
break;
}
case 1:
case 2:
case 3:
private static String compressionName(Form header) {
switch (header.compressionType()) {
case BMHDChunk.COMPRESSION_NONE:
return "None";
case BMHDChunk.COMPRESSION_BYTE_RUN:
return "RLE";
case 4:
case 5:
case 6:
case 7:
case 24:
case 25:
case 32:
csType.setAttribute("name", "RGB");
break;
// Compression type 4 means different things for different FORM types, we support
// Impulse RGB8 RLE compression: 24 bit RGB + 1 bit mask + 7 bit run count
if (header.formType == TYPE_RGB8) {
return "RGB8";
}
default:
csType.setAttribute("name", "Unknown");
return "Unknown";
}
// NOTE: Channels in chroma node reflects channels in color model (see data node, for channels in data)
IIOMetadataNode numChannels = new IIOMetadataNode("NumChannels");
chroma.appendChild(numChannels);
if (colorMap == null && header.bitplanes() == 8) {
numChannels.setAttribute("value", Integer.toString(1));
}
else if (header.bitplanes() == 25 || header.bitplanes() == 32) {
numChannels.setAttribute("value", Integer.toString(4));
}
else {
numChannels.setAttribute("value", Integer.toString(3));
}
IIOMetadataNode blackIsZero = new IIOMetadataNode("BlackIsZero");
chroma.appendChild(blackIsZero);
blackIsZero.setAttribute("value", "TRUE");
// NOTE: TGA files may contain a color map, even if true color...
// Not sure if this is a good idea to expose to the meta data,
// as it might be unexpected... Then again...
if (colorMap != null) {
IIOMetadataNode palette = new IIOMetadataNode("Palette");
chroma.appendChild(palette);
for (int i = 0; i < colorMap.getMapSize(); i++) {
IIOMetadataNode paletteEntry = new IIOMetadataNode("PaletteEntry");
palette.appendChild(paletteEntry);
paletteEntry.setAttribute("index", Integer.toString(i));
paletteEntry.setAttribute("red", Integer.toString(colorMap.getRed(i)));
paletteEntry.setAttribute("green", Integer.toString(colorMap.getGreen(i)));
paletteEntry.setAttribute("blue", Integer.toString(colorMap.getBlue(i)));
}
if (colorMap.getTransparentPixel() != -1) {
IIOMetadataNode backgroundIndex = new IIOMetadataNode("BackgroundIndex");
chroma.appendChild(backgroundIndex);
backgroundIndex.setAttribute("value", Integer.toString(colorMap.getTransparentPixel()));
}
}
// TODO: TVPP TVPaint Project files have a MIXR chunk with a background color
// and also a BGP1 (background pen 1?) and BGP2 chunks
// if (extensions != null && extensions.getBackgroundColor() != 0) {
// Color background = new Color(extensions.getBackgroundColor(), true);
//
// IIOMetadataNode backgroundColor = new IIOMetadataNode("BackgroundColor");
// chroma.appendChild(backgroundColor);
//
// backgroundColor.setAttribute("red", Integer.toString(background.getRed()));
// backgroundColor.setAttribute("green", Integer.toString(background.getGreen()));
// backgroundColor.setAttribute("blue", Integer.toString(background.getBlue()));
// }
return chroma;
}
@Override
protected IIOMetadataNode getStandardCompressionNode() {
if (header.compressionType() == BMHDChunk.COMPRESSION_NONE) {
return null; // All defaults
}
private static int[] bitsPerSample(Form header) {
int bitplanes = header.bitplanes();
IIOMetadataNode node = new IIOMetadataNode("Compression");
IIOMetadataNode compressionTypeName = new IIOMetadataNode("CompressionTypeName");
compressionTypeName.setAttribute("value", "RLE");
node.appendChild(compressionTypeName);
IIOMetadataNode lossless = new IIOMetadataNode("Lossless");
lossless.setAttribute("value", "TRUE");
node.appendChild(lossless);
return node;
}
@Override
protected IIOMetadataNode getStandardDataNode() {
IIOMetadataNode data = new IIOMetadataNode("Data");
// PlanarConfiguration
IIOMetadataNode planarConfiguration = new IIOMetadataNode("PlanarConfiguration");
switch (header.formType) {
case TYPE_DEEP:
case TYPE_TVPP:
case TYPE_RGB8:
case TYPE_PBM:
planarConfiguration.setAttribute("value", "PixelInterleaved");
break;
case TYPE_ILBM:
planarConfiguration.setAttribute("value", "PlaneInterleaved");
break;
default:
planarConfiguration.setAttribute("value", "Unknown " + IFFUtil.toChunkStr(header.formType));
break;
}
data.appendChild(planarConfiguration);
IIOMetadataNode sampleFormat = new IIOMetadataNode("SampleFormat");
sampleFormat.setAttribute("value", colorMap != null ? "Index" : "UnsignedIntegral");
data.appendChild(sampleFormat);
// BitsPerSample
IIOMetadataNode bitsPerSample = new IIOMetadataNode("BitsPerSample");
String value = bitsPerSampleValue(header.bitplanes());
bitsPerSample.setAttribute("value", value);
data.appendChild(bitsPerSample);
// SignificantBitsPerSample not in format
// SampleMSB not in format
return data;
}
private String bitsPerSampleValue(int bitplanes) {
switch (bitplanes) {
case 1:
case 2:
@@ -192,91 +89,47 @@ final class IFFImageMetadata extends AbstractMetadata {
case 6:
case 7:
case 8:
return Integer.toString(bitplanes);
return new int[] {bitplanes};
case 24:
return "8 8 8";
return new int[] {8, 8, 8};
case 25:
if (header.formType != TYPE_RGB8) {
throw new IllegalArgumentException(String.format("25 bit depth only supported for FORM type RGB8: %s", IFFUtil.toChunkStr(header.formType)));
}
return "8 8 8 1";
return new int[] {8, 8, 8, 1};
case 32:
return "8 8 8 8";
return new int[] {8, 8, 8, 8};
default:
throw new IllegalArgumentException("Unknown bit count: " + bitplanes);
}
}
@Override
protected IIOMetadataNode getStandardDimensionNode() {
if (header.aspect() == 0) {
return null;
private static PlanarConfiguration planarConfiguration(Form header) {
switch (header.formType) {
case TYPE_DEEP:
case TYPE_TVPP:
case TYPE_RGB8:
case TYPE_PBM:
return PlanarConfiguration.PixelInterleaved;
case TYPE_ILBM:
return PlanarConfiguration.PlaneInterleaved;
default:
return null;
}
IIOMetadataNode dimension = new IIOMetadataNode("Dimension");
// PixelAspectRatio
IIOMetadataNode pixelAspectRatio = new IIOMetadataNode("PixelAspectRatio");
pixelAspectRatio.setAttribute("value", String.valueOf(header.aspect()));
dimension.appendChild(pixelAspectRatio);
// TODO: HorizontalScreenSize?
// TODO: VerticalScreenSize?
return dimension;
}
@Override
protected IIOMetadataNode getStandardDocumentNode() {
IIOMetadataNode document = new IIOMetadataNode("Document");
IIOMetadataNode formatVersion = new IIOMetadataNode("FormatVersion");
document.appendChild(formatVersion);
formatVersion.setAttribute("value", "1.0");
return document;
}
@Override
protected IIOMetadataNode getStandardTextNode() {
if (meta.isEmpty()) {
return null;
private static List<Map.Entry<String, String>> textEntries(Form header) {
if (header.meta.isEmpty()) {
return emptyList();
}
IIOMetadataNode text = new IIOMetadataNode("Text");
// /Text/TextEntry@keyword = field name, /Text/TextEntry@value = field value.
for (GenericChunk chunk : meta) {
IIOMetadataNode node = new IIOMetadataNode("TextEntry");
node.setAttribute("keyword", IFFUtil.toChunkStr(chunk.chunkId));
node.setAttribute("value", new String(chunk.data, chunk.chunkId == IFF.CHUNK_UTF8 ? StandardCharsets.UTF_8 : StandardCharsets.US_ASCII));
text.appendChild(node);
List<Map.Entry<String, String>> text = new ArrayList<>();
for (GenericChunk chunk : header.meta) {
text.add(new SimpleImmutableEntry<>(toChunkStr(chunk.chunkId),
new String(chunk.data, chunk.chunkId == IFF.CHUNK_UTF8 ? StandardCharsets.UTF_8 : StandardCharsets.US_ASCII)));
}
return text;
}
@Override
protected IIOMetadataNode getStandardTransparencyNode() {
if ((colorMap == null || !colorMap.hasAlpha()) && header.bitplanes() != 32 && header.bitplanes() != 25) {
return null;
}
IIOMetadataNode transparency = new IIOMetadataNode("Transparency");
if (header.bitplanes() == 25 || header.bitplanes() == 32) {
IIOMetadataNode alpha = new IIOMetadataNode("Alpha");
alpha.setAttribute("value", header.premultiplied() ? "premultiplied" : "nonpremultiplied");
transparency.appendChild(alpha);
}
if (colorMap != null && colorMap.getTransparency() == Transparency.BITMASK) {
IIOMetadataNode transparentIndex = new IIOMetadataNode("TransparentIndex");
transparentIndex.setAttribute("value", Integer.toString(colorMap.getTransparentPixel()));
transparency.appendChild(transparentIndex);
}
return transparency;
}
}
@@ -38,14 +38,19 @@ import com.twelvemonkeys.imageio.util.ImageTypeSpecifiers;
import com.twelvemonkeys.io.enc.DecoderStream;
import com.twelvemonkeys.io.enc.PackBitsDecoder;
import javax.imageio.*;
import javax.imageio.IIOException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReadParam;
import javax.imageio.ImageReader;
import javax.imageio.ImageTypeSpecifier;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.spi.ImageReaderSpi;
import javax.imageio.stream.ImageInputStream;
import java.awt.*;
import java.awt.color.ColorSpace;
import java.awt.color.*;
import java.awt.image.*;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
@@ -350,9 +355,7 @@ public final class IFFImageReader extends ImageReaderBase {
@Override
public IIOMetadata getImageMetadata(int imageIndex) throws IOException {
init(imageIndex);
return new IFFImageMetadata(header, header.colorMap());
return new IFFImageMetadata(getRawImageType(imageIndex), header, header.colorMap());
}
@Override