diff --git a/README.md b/README.md index 0c1f030a238383724be47cb8d11622288d86e168..ebe961a9be79952fffa8604d97d2741d7bd31479 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -_*December 5, 2021: Thumbnailator 0.4.15 has been released! +_*January 2, 2022: Thumbnailator 0.4.16 has been released! See [Changes](https://github.com/coobird/thumbnailator/wiki/Changes) for details.*_ _*Thumbnailator is now available through @@ -49,7 +49,7 @@ The following pages have more information on what _Thumbnailator_ can do: * [Features](https://github.com/coobird/thumbnailator/wiki/Features) * [Examples](https://github.com/coobird/thumbnailator/wiki/Examples) -* [Thumbnailator API Documentation](https://coobird.github.io/thumbnailator/javadoc/0.4.14/) +* [Thumbnailator API Documentation](https://coobird.github.io/thumbnailator/javadoc/0.4.16/) * [Frequently Asked Questions](https://github.com/coobird/thumbnailator/wiki/FAQ) # Disclaimer diff --git a/pom.xml b/pom.xml index e6170f9d0693951c15cd097697c99750f74f89b1..fcb1b2fbb9bf461b61d95129164b94c1c7ec9038 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>net.coobird</groupId> <artifactId>thumbnailator</artifactId> - <version>0.4.15</version> + <version>0.4.16</version> <packaging>jar</packaging> <name>thumbnailator</name> <description>Thumbnailator - a thumbnail generation library for Java</description> @@ -186,7 +186,13 @@ <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> - <version>4.10</version> + <version>4.13.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest-core</artifactId> + <version>1.3</version> <scope>test</scope> </dependency> </dependencies> diff --git a/src/main/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSource.java b/src/main/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSource.java index 1c67141c5831f819cb7211128c545c9aecbd6d1b..3c0dbde5f3133bd6a51280e5bf496b67440d4d3b 100644 --- a/src/main/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSource.java +++ b/src/main/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSource.java @@ -1,7 +1,7 @@ /* * Thumbnailator - a thumbnail generation library * - * Copyright (c) 2008-2020 Chris Kroells + * Copyright (c) 2008-2021 Chris Kroells * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -29,6 +29,8 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.Iterator; import java.util.List; @@ -37,6 +39,7 @@ import javax.imageio.ImageReadParam; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; +import net.coobird.thumbnailator.ThumbnailParameter; import net.coobird.thumbnailator.filters.ImageFilter; import net.coobird.thumbnailator.geometry.Region; import net.coobird.thumbnailator.tasks.UnsupportedFormatException; @@ -58,9 +61,9 @@ public class InputStreamImageSource extends AbstractImageSource<InputStream> { private static final int FIRST_IMAGE_INDEX = 0; /** - * The {@link InputStream} from which the source image is to be read. + * A {@link InputStream} from which the source image is to be read. */ - private final InputStream is; + private InputStream is; /** * Instantiates an {@link InputStreamImageSource} with the @@ -77,8 +80,335 @@ public class InputStreamImageSource extends AbstractImageSource<InputStream> { if (is == null) { throw new NullPointerException("InputStream cannot be null."); } - - this.is = is; + + if (!Boolean.getBoolean("thumbnailator.disableExifWorkaround")) { + this.is = new ExifCaptureInputStream(is); + } else { + this.is = is; + } + } + + @Override + public void setThumbnailParameter(ThumbnailParameter param) { + super.setThumbnailParameter(param); + + if (param == null || !param.useExifOrientation()) { + if (is instanceof ExifCaptureInputStream) { + // Revert to original `InputStream` and use that directly. + is = ((ExifCaptureInputStream)is).is; + } + } + } + + /** + * An {@link InputStream} which intercepts the data stream to find Exif + * data and captures it if present. + */ + private static final class ExifCaptureInputStream extends InputStream { + /** + * Original {@link InputStream} which reads from the image source. + */ + private final InputStream is; + + // Following are states for this input stream. + + /** + * Flag to indicate data stream should be intercepted and collected. + */ + private boolean doIntercept = true; + + /** + * A threshold on how much data to be intercepted. + * This is a safety mechanism to prevent buffering too much information. + */ + private static final int INTERCEPT_THRESHOLD = 1024 * 1024; + + /** + * Buffer to collect the input data to read JPEG images for JFIF marker segments. + * This will also be used to store the Exif data, if found. + */ + private byte[] buffer = new byte[0]; + + /** + * Current position for reading the buffer. + */ + int position = 0; + + /** + * Total bytes intercepted from the data stream. + */ + int totalRead = 0; + + /** + * Number of remaining bytes to skip ahead in the buffer. + * This value is positive when next location to skip to is outside the + * buffer's current contents. + */ + int remainingSkip = 0; + + /** + * Marker for the beginning of the APP1 marker segment. + * Its position is where the APP1 marker starts, not the payload. + */ + private int startApp1 = Integer.MIN_VALUE; + + /** + * Marker for the end of the APP1 marker segment. + */ + private int endApp1 = Integer.MAX_VALUE; + + /** + * A flag to indicate that we expect APP1 payload (which contains Exif + * contents) is being streamed, so they should be captured into the + * {@code buffer}. + */ + private boolean doCaptureApp1 = false; + + /** + * A flag to indicate that the {@code buffer} contains the complete + * Exif information. + */ + private boolean hasCapturedExif = false; + + /** + * A flag to indicate whether to output debug logs. + */ + private final boolean isDebug = Boolean.getBoolean("thumbnailator.debugLog.exifWorkaround") + || Boolean.getBoolean("thumbnailator.debugLog"); + + /** + * Returns Exif data captured from the JPEG image. + * @return Returns captured Exif data, or {@code null} if unavailable. + */ + private byte[] getExifData() { + return hasCapturedExif ? buffer : null; + } + + // TODO Any performance penalties? + private ExifCaptureInputStream(InputStream is) { + this.is = is; + } + + /** + * Terminate intercept. + * Drops the collected buffer to relieve pressure on memory. + * + * Do not call this when Exif was found, as buffer (containing Exif) + * will be lost. + */ + private void terminateIntercept() { + doIntercept = false; + buffer = null; + } + + /** + * Debug message. + */ + private void debugln(String format, Object... args) { + if (isDebug) { + System.err.printf("[thumbnailator.exifWorkaround] " + format + "%n", args); + } + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int bytesRead = is.read(b, off, len); + if (bytesRead == -1) { + return bytesRead; + } + + if (!doIntercept) { + debugln("Skip intercept."); + return bytesRead; + } + + if (off != 0) { + debugln("Offset: %s != 0; terminating intercept.", off); + terminateIntercept(); + return bytesRead; + } + + totalRead += bytesRead; + if (totalRead > INTERCEPT_THRESHOLD) { + debugln("Exceeded intercept threshold, terminating intercept. %s > %s", totalRead, INTERCEPT_THRESHOLD); + terminateIntercept(); + return bytesRead; + } + + debugln("Total read: %s", totalRead); + debugln("Bytes read: %s", bytesRead); + + byte[] tmpBuffer = new byte[totalRead]; + System.arraycopy(buffer, 0, tmpBuffer, 0, Math.min(tmpBuffer.length, buffer.length)); + System.arraycopy(b, off, tmpBuffer, totalRead - bytesRead, bytesRead); + buffer = tmpBuffer; + + debugln("Source: %s", Arrays.toString(b)); + debugln("Buffer: %s", Arrays.toString(buffer)); + + while (position < totalRead && (totalRead - position) >= 2) { + debugln("Start loop, position: %s", position); + + if (remainingSkip > 0) { + position += remainingSkip; + remainingSkip = 0; + debugln("Skip requested, new position: %s", position); + continue; + } + + if (doCaptureApp1) { + // Check we can buffer up to "Exif" identifier. + if (startApp1 + 8 > position) { + debugln("APP1 shorter than expected, terminating intercept."); + terminateIntercept(); + break; + } + byte[] header = new byte[4]; + System.arraycopy(buffer, startApp1 + 4, header, 0, header.length); + + if (new String(header).equals("Exif")) { + debugln("Found Exif!"); + hasCapturedExif = true; + doIntercept = false; + byte[] exifData = new byte[endApp1 - (startApp1 + 4)]; + System.arraycopy(buffer, startApp1 + 4, exifData, 0, exifData.length); + buffer = exifData; + break; + } else { + debugln("APP1 was not Exif."); + hasCapturedExif = false; + doIntercept = true; + doCaptureApp1 = false; + } + } + + if (position == 0 && totalRead >= 2) { + // Check the first two bytes of stream to see if SOI exists. + // If SOI is not found, this is not a JPEG. + debugln("Check if JPEG. buffer: %s", Arrays.toString(buffer)); + if (!(buffer[position] == (byte) 0xFF && buffer[position + 1] == (byte) 0xD8)) { + // Not SOI, so it's not a JPEG. + // We no longer need to keep intercepting. + debugln("JFIF SOI not found. Not JPEG."); + terminateIntercept(); + break; + } + + position += 2; + continue; + } + + debugln("Prior to 2-byte section. position: %s, total read: %s", position, totalRead); + if (position + 2 <= totalRead) { + if (buffer[position] == (byte) 0xFF) { + if (buffer[position + 1] >= (byte) 0xD0 && buffer[position + 1] <= (byte) 0xD7) { + // RSTn - a 2-byte marker. + debugln("Found RSTn marker."); + position += 2; + continue; + } else if (buffer[position + 1] == (byte) 0xDA || buffer[position + 1] == (byte) 0xD9) { + // 0xDA -> SOS - Start of Scan + // 0xD9 -> EOI - End of Image + // In both cases, terminate the scan for Exif data. + debugln("Stop scan for Exif. Found: %s, %s", buffer[position], buffer[position + 1]); + terminateIntercept(); + break; + } + } + } + + debugln("Prior to 4-byte section. position: %s, total read: %s", position, totalRead); + if (position + 4 <= totalRead) { + try { + if (buffer[position] == (byte) 0xFF) { + if (buffer[position + 1] == (byte) 0xE1) { + // APP1 + doCaptureApp1 = true; + startApp1 = position; + + // payload + marker + int incrementBy = getPayloadLength(buffer[position + 2], buffer[position + 3]) + 4; + debugln("Prior to 2-byte section. position: %s, total read: %s", position, totalRead); + + int newPosition = incrementBy + position; + endApp1 = newPosition; + debugln("Found APP1. position: %s, total read: %s, increment by: %s", position, totalRead, incrementBy); + debugln("Found APP1. start: %s, end: %s", startApp1, endApp1); + if (newPosition > totalRead) { + remainingSkip = newPosition - totalRead; + position = totalRead; + debugln("Skip request; remaining skip: %s", remainingSkip); + } else { + position = newPosition; + debugln("No skip needed; new position: %s", newPosition); + } + continue; + + } else if (buffer[1] == (byte) 0xDD) { + // DRI (this is a 4-byte marker w/o payload.) + debugln("Found DRI."); + position += 4; + continue; + } + + // Other markers like APP0, DQT don't need any special processing. + + int incrementBy = getPayloadLength(buffer[position + 2], buffer[position + 3]) + 4; + int newPosition = incrementBy + position; + debugln("Other 4-byte. position: %s, total read: %s, increment by: %s", position, totalRead, incrementBy); + debugln("Other 4-byte. start: %s, end: %s", startApp1, endApp1); + if (newPosition > totalRead) { + remainingSkip = newPosition - totalRead; + position = totalRead; + debugln("Skip request; remaining skip: %s", remainingSkip); + } else { + position = newPosition; + debugln("No skip needed; new position: %s", newPosition); + } + continue; + } + } catch (Exception e) { + // Immediately drop everything, as we can't recover. + // TODO Record what went wrong. + debugln("[Exception] Exception thrown. Terminating intercept."); + debugln("[Exception] %s", e.toString()); + for (StackTraceElement el : e.getStackTrace()) { + debugln("[Exception] %s", el.toString()); + } + terminateIntercept(); + break; + } + } + + terminateIntercept(); + debugln("Shouldn't be here. Terminating intercept."); + break; + } + + return bytesRead; + } + + @Override + public int read() throws IOException { + return is.read(); + } + + /** + * Returns the payload length from the marker header. + * @param a First byte of payload length. + * @param b Second byte of payload length. + * @return Length as an integer. + */ + private static int getPayloadLength(byte a, byte b) { + int length = ByteBuffer.wrap(new byte[] {a, b}).getShort() - 2; + if (length <= 0) { + throw new IllegalStateException( + "Expected a positive payload length, but was " + length + ); + } + + return length; + } } public BufferedImage read() throws IOException { @@ -136,12 +466,28 @@ public class InputStreamImageSource extends AbstractImageSource<InputStream> { } private BufferedImage readImage(ImageReader reader) throws IOException { - inputFormatName = reader.getFormatName(); try { if (param.useExifOrientation()) { - Orientation orientation; - orientation = ExifUtils.getExifOrientation(reader, FIRST_IMAGE_INDEX); + Orientation orientation = null; + + // Attempt to use Exif reader of the ImageReader. + // If the ImageReader fails like seen in Issue #108, use the + // backup method of using the captured Exif data. + boolean useExifFromRawData = false; + try { + orientation = ExifUtils.getExifOrientation(reader, FIRST_IMAGE_INDEX); + } catch (Exception e) { + // TODO Would be useful to capture why it didn't work. + useExifFromRawData = true; + } + + if (useExifFromRawData && is instanceof ExifCaptureInputStream) { + byte[] exifData = ((ExifCaptureInputStream)is).getExifData(); + if (exifData != null) { + orientation = ExifUtils.getOrientationFromExif(exifData); + } + } // Skip this code block if there's no rotation needed. if (orientation != null && orientation != Orientation.TOP_LEFT) { @@ -159,6 +505,8 @@ public class InputStreamImageSource extends AbstractImageSource<InputStream> { // TODO Ought to have some way to track errors. } + inputFormatName = reader.getFormatName(); + ImageReadParam irParam = reader.getDefaultReadParam(); int width = reader.getWidth(FIRST_IMAGE_INDEX); int height = reader.getHeight(FIRST_IMAGE_INDEX); @@ -177,9 +525,9 @@ public class InputStreamImageSource extends AbstractImageSource<InputStream> { * https://github.com/coobird/thumbnailator/issues/69 */ if (param != null && - "true".equals(System.getProperty("thumbnailator.conserveMemoryWorkaround")) && + Boolean.getBoolean("thumbnailator.conserveMemoryWorkaround") && width > 1800 && height > 1800 && - (width * height * 4 > Runtime.getRuntime().freeMemory() / 4) + (width * height * 4L > Runtime.getRuntime().freeMemory() / 4) ) { int subsampling = 1; diff --git a/src/main/java/net/coobird/thumbnailator/util/exif/ExifUtils.java b/src/main/java/net/coobird/thumbnailator/util/exif/ExifUtils.java index 9e9f634b70bd6850b95944e8cf27d94dbb7e14a9..889ac83d316b72f348b39b73e38042ef493111ca 100644 --- a/src/main/java/net/coobird/thumbnailator/util/exif/ExifUtils.java +++ b/src/main/java/net/coobird/thumbnailator/util/exif/ExifUtils.java @@ -1,7 +1,7 @@ /* * Thumbnailator - a thumbnail generation library * - * Copyright (c) 2008-2020 Chris Kroells + * Copyright (c) 2008-2021 Chris Kroells * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -60,6 +60,7 @@ public final class ExifUtils { * metadata should be read from. * @return The orientation information obtained from the * Exif metadata, as a {@link Orientation} enum. + * Returns {@code null} if no orientation is found. * @throws IOException When an error occurs during reading. * @throws IllegalArgumentException If the {@link ImageReader} does not * have the target image set, or if the @@ -97,7 +98,15 @@ public final class ExifUtils { return null; } - private static Orientation getOrientationFromExif(byte[] exifData) { + /** + * Returns the orientation obtained from the Exif metadata. + * + * @param exifData A byte array containing Exif data. + * @return The orientation information obtained from the + * Exif metadata, as a {@link Orientation} enum. + * Returns {@code null} if no orientation is found. + */ + public static Orientation getOrientationFromExif(byte[] exifData) { // Needed to make byte-wise reading easier. ByteBuffer buffer = ByteBuffer.wrap(exifData); diff --git a/src/test/java/net/coobird/thumbnailator/tasks/io/FileImageSourceTest.java b/src/test/java/net/coobird/thumbnailator/tasks/io/FileImageSourceTest.java index 8c9cba06aa3ac6079e3b70baafb4d1eaffc13b37..0a79b108851ec540991eb9f4b1f7c3106c29fdab 100644 --- a/src/test/java/net/coobird/thumbnailator/tasks/io/FileImageSourceTest.java +++ b/src/test/java/net/coobird/thumbnailator/tasks/io/FileImageSourceTest.java @@ -1,7 +1,7 @@ /* * Thumbnailator - a thumbnail generation library * - * Copyright (c) 2008-2020 Chris Kroells + * Copyright (c) 2008-2021 Chris Kroells * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -24,9 +24,6 @@ package net.coobird.thumbnailator.tasks.io; -import static org.junit.Assert.*; -import static org.junit.matchers.JUnitMatchers.*; - import java.awt.image.BufferedImage; import java.io.File; import java.io.FileNotFoundException; @@ -48,6 +45,12 @@ import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; public class FileImageSourceTest { /** diff --git a/src/test/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSourceMalformedTest.java b/src/test/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSourceMalformedTest.java new file mode 100644 index 0000000000000000000000000000000000000000..7e05f7e3b2525e426f173d2b0163efbe882b8522 --- /dev/null +++ b/src/test/java/net/coobird/thumbnailator/tasks/io/InputStreamImageSourceMalformedTest.java @@ -0,0 +1,96 @@ +/* + * Thumbnailator - a thumbnail generation library + * + * Copyright (c) 2008-2021 Chris Kroells + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package net.coobird.thumbnailator.tasks.io; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@RunWith(Parameterized.class) +public class InputStreamImageSourceMalformedTest { + + @Parameterized.Parameters(name = "type={0}, length={1}") + public static Collection<Object> testCases() { + List<Object[]> cases = new ArrayList<Object[]>(); + for (String type : Arrays.asList("jpg", "png", "bmp")) { + for (int i = 1; i <= 40; i++) { + cases.add(new Object[] { type, i }); + } + } + return Arrays.asList(cases.toArray()); + } + + @Parameterized.Parameter + public String type; + + @Parameterized.Parameter(value = 1) + public Integer length; + + @Before @After + public void cleanup() { + System.clearProperty("thumbnailator.disableExifWorkaround"); + } + + @Test + public void terminatesProperlyWithWorkaround() { + runTest(); + } + + @Test + public void terminatesProperlyWithoutWorkaround() { + System.setProperty("thumbnailator.disableExifWorkaround", "true"); + runTest(); + } + + /** + * Test to check that reading an abnormal file won't cause image reading + * to end up in a bad state like in an infinite loop. + */ + private void runTest() { + try { + byte[] bytes = new byte[length]; + InputStream sourceIs = ClassLoader.getSystemResourceAsStream(String.format("Thumbnailator/grid.%s", type)); + sourceIs.read(bytes); + sourceIs.close(); + + ByteArrayInputStream is = new ByteArrayInputStream(bytes); + InputStreamImageSource source = new InputStreamImageSource(is); + + source.read(); + + } catch (Exception e) { + // terminates properly, even if an exception is thrown. + } + } +} diff --git a/src/test/java/net/coobird/thumbnailator/util/exif/ExifWorkaroundTest.java b/src/test/java/net/coobird/thumbnailator/util/exif/ExifWorkaroundTest.java new file mode 100644 index 0000000000000000000000000000000000000000..cd2f119ad6a4b5ba109bfbfbcc6de45aeb8e8875 --- /dev/null +++ b/src/test/java/net/coobird/thumbnailator/util/exif/ExifWorkaroundTest.java @@ -0,0 +1,137 @@ +/* + * Thumbnailator - a thumbnail generation library + * + * Copyright (c) 2008-2021 Chris Kroells + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package net.coobird.thumbnailator.util.exif; + +import net.coobird.thumbnailator.Thumbnails; +import net.coobird.thumbnailator.test.BufferedImageAssert; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@RunWith(Parameterized.class) +public class ExifWorkaroundTest { + + @Parameterized.Parameters(name = "tags={0}") + public static Collection<List<String>> tagOrder() { + return Arrays.asList( + Arrays.asList("app0.segment", "exif.segment"), + Arrays.asList("exif.segment", "app0.segment"), + Arrays.asList("app0.segment", "exif.segment", "xmp.segment"), + Arrays.asList("app0.segment", "xmp.segment", "exif.segment"), + Arrays.asList("exif.segment", "app0.segment", "xmp.segment"), + Arrays.asList("xmp.segment", "app0.segment", "exif.segment"), + Arrays.asList("exif.segment", "xmp.segment", "app0.segment"), + Arrays.asList("xmp.segment", "exif.segment", "app0.segment") + ); + } + + @Parameterized.Parameter + public List<String> tags; + + private InputStream getFromResource(String name) { + return this.getClass().getClassLoader().getResourceAsStream("Exif/fragments/" + name); + } + + private InputStream buildJpeg() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + List<String> resources = new ArrayList<String>(); + resources.add("soi.segment"); + resources.addAll(tags); + resources.add("rest"); + + for (String resource : resources) { + InputStream is = getFromResource(resource); + while (is.available() > 0) { + baos.write(is.read()); + } + } + + return new ByteArrayInputStream(baos.toByteArray()); + } + + @Test + public void withWorkaround() throws IOException { + BufferedImage result = Thumbnails.of(buildJpeg()) + .scale(1.0f) + .asBufferedImage(); + + assertPasses(result); + } + + @Test + public void withoutWorkaround() throws IOException { + System.setProperty("thumbnailator.disableExifWorkaround", "true"); + + BufferedImage result = Thumbnails.of(buildJpeg()) + .scale(1.0f) + .asBufferedImage(); + + if (tags.get(0).equals("app0.segment")) { + assertPasses(result); + } else { + assertFails(result); + } + } + + @Before @After + public void cleanup() { + System.clearProperty("thumbnailator.disableExifWorkaround"); + } + + private void assertPasses(BufferedImage result) { + BufferedImageAssert.assertMatches( + result, + new float[] { + 1, 1, 1, + 1, 1, 1, + 1, 0, 0, + } + ); + } + + private void assertFails(BufferedImage result) { + BufferedImageAssert.assertMatches( + result, + new float[] { + 1, 1, 1, + 1, 1, 1, + 0, 0, 1, + } + ); + } +} diff --git a/src/test/resources/Exif/fragments/README b/src/test/resources/Exif/fragments/README new file mode 100644 index 0000000000000000000000000000000000000000..c6a231ab97204cc30aedc91fdf7b25b67e2105c5 --- /dev/null +++ b/src/test/resources/Exif/fragments/README @@ -0,0 +1,3 @@ +Files here are fragments from adding JFIF segments to "source_2.jpg". +XMP tag is included as it is also a APP1 like Exif. +The Exif reader should cope with XMP properly. \ No newline at end of file diff --git a/src/test/resources/Exif/fragments/app0.segment b/src/test/resources/Exif/fragments/app0.segment new file mode 100644 index 0000000000000000000000000000000000000000..d12539d4a7ece8537e7731bac611f496c442013e Binary files /dev/null and b/src/test/resources/Exif/fragments/app0.segment differ diff --git a/src/test/resources/Exif/fragments/exif.segment b/src/test/resources/Exif/fragments/exif.segment new file mode 100644 index 0000000000000000000000000000000000000000..047b2ec3f4b1b36554f6c33950fbee956b83ff86 Binary files /dev/null and b/src/test/resources/Exif/fragments/exif.segment differ diff --git a/src/test/resources/Exif/fragments/rest b/src/test/resources/Exif/fragments/rest new file mode 100644 index 0000000000000000000000000000000000000000..eefda8505b99bc4e320e45fd858c7106b2e9dddb Binary files /dev/null and b/src/test/resources/Exif/fragments/rest differ diff --git a/src/test/resources/Exif/fragments/soi.segment b/src/test/resources/Exif/fragments/soi.segment new file mode 100644 index 0000000000000000000000000000000000000000..d17e4da5bae433207703630e22ee51f8ba636f37 --- /dev/null +++ b/src/test/resources/Exif/fragments/soi.segment @@ -0,0 +1 @@ +�� \ No newline at end of file diff --git a/src/test/resources/Exif/fragments/xmp.segment b/src/test/resources/Exif/fragments/xmp.segment new file mode 100644 index 0000000000000000000000000000000000000000..fd213c3ff508a006bd8ce62ff53ac584d75ace96 Binary files /dev/null and b/src/test/resources/Exif/fragments/xmp.segment differ