diff --git a/imageio/imageio-jpeg/src/main/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriter.java b/imageio/imageio-jpeg/src/main/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriter.java index 0455616c..8cc404cd 100644 --- a/imageio/imageio-jpeg/src/main/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriter.java +++ b/imageio/imageio-jpeg/src/main/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriter.java @@ -29,6 +29,7 @@ package com.twelvemonkeys.imageio.plugins.jpeg; import com.twelvemonkeys.imageio.ImageWriterBase; +import com.twelvemonkeys.imageio.metadata.jpeg.JPEG; import com.twelvemonkeys.imageio.util.ProgressListenerBase; import javax.imageio.IIOImage; @@ -37,14 +38,19 @@ import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; import javax.imageio.event.IIOWriteWarningListener; import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; import java.awt.*; -import java.awt.image.BufferedImage; -import java.awt.image.Raster; -import java.awt.image.RenderedImage; +import java.awt.color.ColorSpace; +import java.awt.color.ICC_ColorSpace; +import java.awt.color.ICC_Profile; +import java.awt.image.*; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Locale; +import static com.twelvemonkeys.imageio.plugins.jpeg.JPEGImage10MetadataCleaner.JAVAX_IMAGEIO_JPEG_IMAGE_1_0; + /** * JPEGImageWriter * @@ -53,8 +59,6 @@ import java.util.Locale; * @version $Id: JPEGImageWriter.java,v 1.0 06.02.12 16:39 haraldk Exp$ */ public final class JPEGImageWriter extends ImageWriterBase { - // TODO: Extend with functionality to be able to write CMYK JPEGs as well? - /** Our JPEG writing delegate */ private final ImageWriter delegate; @@ -75,11 +79,13 @@ public final class JPEGImageWriter extends ImageWriterBase { @Override protected void resetMembers() { + delegate.reset(); + installListeners(); } @Override - public void setOutput(Object output) { + public void setOutput(final Object output) { super.setOutput(output); delegate.setOutput(output); @@ -111,32 +117,32 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public IIOMetadata getDefaultStreamMetadata(ImageWriteParam param) { + public IIOMetadata getDefaultStreamMetadata(final ImageWriteParam param) { return delegate.getDefaultStreamMetadata(param); } @Override - public IIOMetadata getDefaultImageMetadata(ImageTypeSpecifier imageType, ImageWriteParam param) { + public IIOMetadata getDefaultImageMetadata(final ImageTypeSpecifier imageType, final ImageWriteParam param) { return delegate.getDefaultImageMetadata(imageType, param); } @Override - public IIOMetadata convertStreamMetadata(IIOMetadata inData, ImageWriteParam param) { + public IIOMetadata convertStreamMetadata(final IIOMetadata inData, final ImageWriteParam param) { return delegate.convertStreamMetadata(inData, param); } @Override - public IIOMetadata convertImageMetadata(IIOMetadata inData, ImageTypeSpecifier imageType, ImageWriteParam param) { + public IIOMetadata convertImageMetadata(final IIOMetadata inData, final ImageTypeSpecifier imageType, final ImageWriteParam param) { return delegate.convertImageMetadata(inData, imageType, param); } @Override - public int getNumThumbnailsSupported(ImageTypeSpecifier imageType, ImageWriteParam param, IIOMetadata streamMetadata, IIOMetadata imageMetadata) { + public int getNumThumbnailsSupported(final ImageTypeSpecifier imageType, final ImageWriteParam param, final IIOMetadata streamMetadata, final IIOMetadata imageMetadata) { return delegate.getNumThumbnailsSupported(imageType, param, streamMetadata, imageMetadata); } @Override - public Dimension[] getPreferredThumbnailSizes(ImageTypeSpecifier imageType, ImageWriteParam param, IIOMetadata streamMetadata, IIOMetadata imageMetadata) { + public Dimension[] getPreferredThumbnailSizes(final ImageTypeSpecifier imageType, final ImageWriteParam param, final IIOMetadata streamMetadata, final IIOMetadata imageMetadata) { return delegate.getPreferredThumbnailSizes(imageType, param, streamMetadata, imageMetadata); } @@ -146,18 +152,105 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public void write(IIOMetadata streamMetadata, IIOImage image, ImageWriteParam param) throws IOException { - delegate.write(streamMetadata, image, param); + public void write(final IIOMetadata streamMetadata, final IIOImage image, final ImageWriteParam param) throws IOException { + if (isDestinationCMYK(image, param)) { + writeCMYK(streamMetadata, image, param); + } + else { + delegate.write(streamMetadata, image, param); + } } - @Override - public void write(IIOImage image) throws IOException { - delegate.write(image); + private boolean isDestinationCMYK(final IIOImage image, final ImageWriteParam param) { + // If destination type != null, rendered image type doesn't matter + return !image.hasRaster() && image.getRenderedImage().getColorModel().getColorSpace().getType() == ColorSpace.TYPE_CMYK + || param != null && param.getDestinationType() != null && param.getDestinationType().getColorModel().getColorSpace().getType() == ColorSpace.TYPE_CMYK; } - @Override - public void write(RenderedImage image) throws IOException { - delegate.write(image); + private void writeCMYK(final IIOMetadata streamMetadata, final IIOImage image, final ImageWriteParam param) throws IOException { + RenderedImage renderedImage = image.getRenderedImage(); + boolean overrideDestination = param != null && param.getDestinationType() != null; + ImageTypeSpecifier destinationType = overrideDestination + ? param.getDestinationType() + : ImageTypeSpecifier.createFromRenderedImage(renderedImage); + + ColorSpace cmykCS = destinationType.getColorModel().getColorSpace(); + + IIOMetadata metadata = delegate.getDefaultImageMetadata(destinationType, param); + + IIOMetadataNode jpegMeta = new IIOMetadataNode(JAVAX_IMAGEIO_JPEG_IMAGE_1_0); + jpegMeta.appendChild(new IIOMetadataNode("JPEGVariety")); // Just leave as default + + IIOMetadataNode markerSequence = new IIOMetadataNode("markerSequence"); + jpegMeta.appendChild(markerSequence); + + IIOMetadataNode app14Adobe = new IIOMetadataNode("app14Adobe"); + app14Adobe.setAttribute("transform", "0"); // 0 for CMYK, 2 for YCCK + markerSequence.appendChild(app14Adobe); + + if (cmykCS instanceof ICC_ColorSpace) { + ICC_Profile profile = ((ICC_ColorSpace) cmykCS).getProfile(); + byte[] profileData = profile.getData(); + + String segmentId = "ICC_PROFILE"; + int idLength = segmentId.length(); + byte[] segmentIdBytes = segmentId.getBytes(StandardCharsets.US_ASCII); + + int maxSegmentLength = Short.MAX_VALUE - Short.MIN_VALUE - idLength - 3 - 2; + + int count = (int) Math.ceil(profileData.length / (float) maxSegmentLength); + + for (int i = 0; i < count; i++) { + // Insert unknown marker tags, as app2ICC can only be subtag of jpegVariety/JFIF :-P + IIOMetadataNode icc = new IIOMetadataNode("unknown"); + icc.setAttribute("MarkerTag", String.valueOf(JPEG.APP2 & 0xFF)); + + int segmentLength = Math.min(maxSegmentLength, profileData.length - i * maxSegmentLength); + byte[] data = new byte[idLength + 3 + segmentLength]; + + System.arraycopy(segmentIdBytes, 0, data, 0, idLength); + data[idLength] = 0; // null-terminator + data[idLength + 1] = (byte) (1 + i); // index + data[idLength + 2] = (byte) count; + System.arraycopy(profileData, i * maxSegmentLength, data, idLength + 3, segmentLength); + + icc.setUserObject(data); + + markerSequence.appendChild(icc); + } + } + + metadata.mergeTree(JAVAX_IMAGEIO_JPEG_IMAGE_1_0, jpegMeta); + + Raster raster = new InvertedRaster(getRaster(renderedImage)); + + // TODO: For YCCK we need oposite conversion +// for (int i = 0; i < data.length; i += 4) { +// YCbCrConverter.convertYCbCr2RGB(data, data, i); +// } + + if (overrideDestination) { + // Avoid javax.imageio.IIOException: Invalid argument to native writeImage + param.setDestinationType(null); + } + + try { + delegate.write(streamMetadata, new IIOImage(raster, null, metadata), param); + } + finally { + if (overrideDestination) { + param.setDestinationType(destinationType); + } + } + } + + // TODO: Candidate util method + private static Raster getRaster(final RenderedImage image) { + return image instanceof BufferedImage + ? ((BufferedImage) image).getRaster() + : image.getNumXTiles() == 1 && image.getNumYTiles() == 1 + ? image.getTile(0, 0) + : image.getData(); } @Override @@ -166,12 +259,12 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public void prepareWriteSequence(IIOMetadata streamMetadata) throws IOException { + public void prepareWriteSequence(final IIOMetadata streamMetadata) throws IOException { delegate.prepareWriteSequence(streamMetadata); } @Override - public void writeToSequence(IIOImage image, ImageWriteParam param) throws IOException { + public void writeToSequence(final IIOImage image, final ImageWriteParam param) throws IOException { delegate.writeToSequence(image, param); } @@ -186,37 +279,37 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public void replaceStreamMetadata(IIOMetadata streamMetadata) throws IOException { + public void replaceStreamMetadata(final IIOMetadata streamMetadata) throws IOException { delegate.replaceStreamMetadata(streamMetadata); } @Override - public boolean canReplaceImageMetadata(int imageIndex) throws IOException { + public boolean canReplaceImageMetadata(final int imageIndex) throws IOException { return delegate.canReplaceImageMetadata(imageIndex); } @Override - public void replaceImageMetadata(int imageIndex, IIOMetadata imageMetadata) throws IOException { + public void replaceImageMetadata(final int imageIndex, final IIOMetadata imageMetadata) throws IOException { delegate.replaceImageMetadata(imageIndex, imageMetadata); } @Override - public boolean canInsertImage(int imageIndex) throws IOException { + public boolean canInsertImage(final int imageIndex) throws IOException { return delegate.canInsertImage(imageIndex); } @Override - public void writeInsert(int imageIndex, IIOImage image, ImageWriteParam param) throws IOException { + public void writeInsert(final int imageIndex, final IIOImage image, final ImageWriteParam param) throws IOException { delegate.writeInsert(imageIndex, image, param); } @Override - public boolean canRemoveImage(int imageIndex) throws IOException { + public boolean canRemoveImage(final int imageIndex) throws IOException { return delegate.canRemoveImage(imageIndex); } @Override - public void removeImage(int imageIndex) throws IOException { + public void removeImage(final int imageIndex) throws IOException { delegate.removeImage(imageIndex); } @@ -226,7 +319,10 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public void prepareWriteEmpty(IIOMetadata streamMetadata, ImageTypeSpecifier imageType, int width, int height, IIOMetadata imageMetadata, List thumbnails, ImageWriteParam param) throws IOException { + public void prepareWriteEmpty(final IIOMetadata streamMetadata, final ImageTypeSpecifier imageType, + final int width, final int height, + final IIOMetadata imageMetadata, final List thumbnails, + final ImageWriteParam param) throws IOException { delegate.prepareWriteEmpty(streamMetadata, imageType, width, height, imageMetadata, thumbnails, param); } @@ -236,12 +332,15 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public boolean canInsertEmpty(int imageIndex) throws IOException { + public boolean canInsertEmpty(final int imageIndex) throws IOException { return delegate.canInsertEmpty(imageIndex); } @Override - public void prepareInsertEmpty(int imageIndex, ImageTypeSpecifier imageType, int width, int height, IIOMetadata imageMetadata, List thumbnails, ImageWriteParam param) throws IOException { + public void prepareInsertEmpty(final int imageIndex, final ImageTypeSpecifier imageType, + final int width, final int height, + final IIOMetadata imageMetadata, final List thumbnails, + final ImageWriteParam param) throws IOException { delegate.prepareInsertEmpty(imageIndex, imageType, width, height, imageMetadata, thumbnails, param); } @@ -251,22 +350,22 @@ public final class JPEGImageWriter extends ImageWriterBase { } @Override - public boolean canReplacePixels(int imageIndex) throws IOException { + public boolean canReplacePixels(final int imageIndex) throws IOException { return delegate.canReplacePixels(imageIndex); } @Override - public void prepareReplacePixels(int imageIndex, Rectangle region) throws IOException { + public void prepareReplacePixels(final int imageIndex, final Rectangle region) throws IOException { delegate.prepareReplacePixels(imageIndex, region); } @Override - public void replacePixels(RenderedImage image, ImageWriteParam param) throws IOException { + public void replacePixels(final RenderedImage image, final ImageWriteParam param) throws IOException { delegate.replacePixels(image, param); } @Override - public void replacePixels(Raster raster, ImageWriteParam param) throws IOException { + public void replacePixels(final Raster raster, final ImageWriteParam param) throws IOException { delegate.replacePixels(raster, param); } @@ -293,6 +392,28 @@ public final class JPEGImageWriter extends ImageWriterBase { delegate.dispose(); } + /** + * Helper class, returns sample values inverted, + * as CMYK values needs to be written inverted (255 - value). + */ + private static class InvertedRaster extends WritableRaster { + InvertedRaster(final Raster raster) { + super(raster.getSampleModel(), new DataBuffer(raster.getDataBuffer().getDataType(), raster.getDataBuffer().getSize()) { + private final DataBuffer delegate = raster.getDataBuffer(); + + @Override + public int getElem(final int bank, final int i) { + return (255 - delegate.getElem(bank, i)); + } + + @Override + public void setElem(int bank, int i, int val) { + throw new UnsupportedOperationException("setElem"); + } + }, new Point()); + } + } + private class ProgressDelegator extends ProgressListenerBase implements IIOWriteWarningListener { @Override public void imageComplete(ImageWriter source) { diff --git a/imageio/imageio-jpeg/src/test/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriterTest.java b/imageio/imageio-jpeg/src/test/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriterTest.java index 6ca71f38..3f0fe197 100644 --- a/imageio/imageio-jpeg/src/test/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriterTest.java +++ b/imageio/imageio-jpeg/src/test/java/com/twelvemonkeys/imageio/plugins/jpeg/JPEGImageWriterTest.java @@ -28,18 +28,35 @@ package com.twelvemonkeys.imageio.plugins.jpeg; +import com.twelvemonkeys.imageio.stream.ByteArrayImageInputStream; import com.twelvemonkeys.imageio.util.IIOUtil; import com.twelvemonkeys.imageio.util.ImageWriterAbstractTestCase; +import org.junit.Test; +import org.w3c.dom.NodeList; -import javax.imageio.ImageWriter; +import javax.imageio.*; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataFormatImpl; +import javax.imageio.metadata.IIOMetadataNode; import javax.imageio.spi.IIORegistry; import javax.imageio.spi.ImageWriterSpi; +import javax.imageio.stream.ImageInputStream; +import javax.imageio.stream.ImageOutputStream; +import javax.imageio.stream.MemoryCacheImageOutputStream; +import java.awt.*; +import java.awt.color.ColorSpace; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URL; import java.util.Arrays; +import java.util.Iterator; import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + /** * JPEGImageWriterTest * @@ -76,4 +93,88 @@ public class JPEGImageWriterTest extends ImageWriterAbstractTestCase { new BufferedImage(32, 20, BufferedImage.TYPE_BYTE_GRAY) ); } + + @Test + public void testReaderForWriter() { + ImageWriter writer = createImageWriter(); + ImageReader reader = ImageIO.getImageReader(writer); + assertNotNull(reader); + assertEquals(writer.getClass().getPackage(), reader.getClass().getPackage()); + } + + private ByteArrayOutputStream transcode(final ImageReader reader, final URL resource, final ImageWriter writer, int outCSType) throws IOException { + try (ImageInputStream input = ImageIO.createImageInputStream(resource)) { + reader.setInput(input); + ImageTypeSpecifier specifier = null; + + Iterator types = reader.getImageTypes(0); + while (types.hasNext()) { + ImageTypeSpecifier type = types.next(); + + if (type.getColorModel().getColorSpace().getType() == outCSType) { + specifier = type; + break; + } + } + + // Read image with requested color space + ImageReadParam readParam = reader.getDefaultReadParam(); + readParam.setSourceRegion(new Rectangle(Math.min(100, reader.getWidth(0)), Math.min(100, reader.getHeight(0)))); + readParam.setDestinationType(specifier); + IIOImage image = reader.readAll(0, readParam); + + // Write it back + ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024); + try (ImageOutputStream output = new MemoryCacheImageOutputStream(bytes)) { + writer.setOutput(output); + ImageWriteParam writeParam = writer.getDefaultWriteParam(); + writeParam.setDestinationType(specifier); + writer.write(null, image, writeParam); + + return bytes; + } + } + } + + @Test + public void testTranscodeWithMetadataRGBtoRGB() throws IOException { + ImageWriter writer = createImageWriter(); + ImageReader reader = ImageIO.getImageReader(writer); + + ByteArrayOutputStream stream = transcode(reader, getClassLoaderResource("/jpeg/jfif-jfxx-thumbnail-olympus-d320l.jpg"), writer, ColorSpace.TYPE_RGB); + +// FileUtil.write(new File("/Downloads/foo-rgb.jpg"), stream.toByteArray()); + + // TODO: Validate that correct warnings are emitted (if any are needed?) + + reader.reset(); + reader.setInput(new ByteArrayImageInputStream(stream.toByteArray())); + BufferedImage image = reader.read(0); + assertNotNull(image); + } + + @Test + public void testTranscodeWithMetadataCMYKtoCMYK() throws IOException { + ImageWriter writer = createImageWriter(); + ImageReader reader = ImageIO.getImageReader(writer); + + // TODO: Find a smaller test sample, to waste less time? + ByteArrayOutputStream stream = transcode(reader, getClassLoaderResource("/jpeg/cmyk-sample-multiple-chunk-icc.jpg"), writer, ColorSpace.TYPE_CMYK); + + reader.reset(); + reader.setInput(new ByteArrayImageInputStream(stream.toByteArray())); + + BufferedImage image = reader.read(0); + assertNotNull(image); + assertEquals(100, image.getWidth()); + assertEquals(100, image.getHeight()); + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode standard = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + NodeList colorSpaceType = standard.getElementsByTagName("ColorSpaceType"); + assertEquals(1, colorSpaceType.getLength()); + assertEquals("CMYK", ((IIOMetadataNode) colorSpaceType.item(0)).getAttribute("name")); + } + + // TODO: YCCK }