├── settings.gradle ├── src ├── test │ ├── resources │ │ ├── china.bmp │ │ ├── sea.png │ │ ├── avatar.jpg │ │ ├── volcano.jpg │ │ ├── website.jpg │ │ ├── colombia.gif │ │ ├── logotype.png │ │ ├── simcards.jpg │ │ ├── thailand.jpg │ │ ├── server-jfif-app0-marker.png │ │ ├── daltonic-with-icc-profile-srgb.jpg │ │ ├── daltonic-without-icc-profile.jpg │ │ ├── steam-bogus-input-colorspace.png │ │ ├── button-buggy-metadata-components.png │ │ ├── invalid-error-metadata-components.gif │ │ └── daltonic-with-icc-profile-adobergb1998.jpg │ └── java │ │ ├── file │ │ ├── BinaryFileWriter.java │ │ └── BinaryFileReader.java │ │ ├── MissingHuffmanCodeTableEntryTest.java │ │ ├── LosslessTest.java │ │ ├── JpegCheckerTest.java │ │ ├── metadata │ │ ├── MetadataReader.java │ │ ├── MetadataParser.java │ │ └── MetadataDisplayer.java │ │ ├── JpegCompressorTest.java │ │ ├── MaxWeightTest.java │ │ ├── HardwareUseTest.java │ │ ├── BuggyPicturesTest.java │ │ ├── PngToJpegTest.java │ │ ├── OptimizeMetadataTest.java │ │ ├── OptimizeAllImagesOnDisk.java │ │ ├── PublicApiTest.java │ │ ├── MaxDiffTest.java │ │ ├── IccProfileTest.java │ │ ├── KeepMetadataTest.java │ │ └── BaseTest.java └── main │ └── java │ └── com │ └── fewlaps │ └── slimjpg │ ├── core │ ├── util │ │ ├── JpegChecker.java │ │ ├── InputStreamToByteArray.java │ │ ├── ReadableUtils.java │ │ ├── JpegCompressor.java │ │ └── BufferedImageComparator.java │ ├── InternalResult.kt │ ├── ResultStatisticsCalculator.kt │ ├── Result.kt │ └── JpegOptimizer.kt │ ├── SlimJpg.kt │ └── RequestCreator.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github └── workflows │ └── gradle.yml ├── .travis.yml ├── .gitignore ├── gradlew.bat ├── gradlew └── README.md /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'slimjpg' 2 | 3 | -------------------------------------------------------------------------------- /src/test/resources/china.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/china.bmp -------------------------------------------------------------------------------- /src/test/resources/sea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/sea.png -------------------------------------------------------------------------------- /src/test/resources/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/avatar.jpg -------------------------------------------------------------------------------- /src/test/resources/volcano.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/volcano.jpg -------------------------------------------------------------------------------- /src/test/resources/website.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/website.jpg -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/colombia.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/colombia.gif -------------------------------------------------------------------------------- /src/test/resources/logotype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/logotype.png -------------------------------------------------------------------------------- /src/test/resources/simcards.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/simcards.jpg -------------------------------------------------------------------------------- /src/test/resources/thailand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/thailand.jpg -------------------------------------------------------------------------------- /src/test/resources/server-jfif-app0-marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/server-jfif-app0-marker.png -------------------------------------------------------------------------------- /src/test/resources/daltonic-with-icc-profile-srgb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/daltonic-with-icc-profile-srgb.jpg -------------------------------------------------------------------------------- /src/test/resources/daltonic-without-icc-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/daltonic-without-icc-profile.jpg -------------------------------------------------------------------------------- /src/test/resources/steam-bogus-input-colorspace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/steam-bogus-input-colorspace.png -------------------------------------------------------------------------------- /src/test/resources/button-buggy-metadata-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/button-buggy-metadata-components.png -------------------------------------------------------------------------------- /src/test/resources/invalid-error-metadata-components.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/invalid-error-metadata-components.gif -------------------------------------------------------------------------------- /src/test/resources/daltonic-with-icc-profile-adobergb1998.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fewlaps/slim-jpg/HEAD/src/test/resources/daltonic-with-icc-profile-adobergb1998.jpg -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/util/JpegChecker.java: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core.util; 2 | 3 | public class JpegChecker { 4 | 5 | public boolean isJpeg(byte[] source) { 6 | return source[0] == -1 && source[1] == -40; 7 | } 8 | 9 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/InternalResult.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core 2 | 3 | data class InternalResult( 4 | val picture: ByteArray, 5 | val jpegQualityUsed: Int, 6 | val iterationsMade: Int, 7 | val internalError: Exception? = null) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Sep 17 23:06:17 CEST 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip 7 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up JDK 1.8 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 1.8 16 | - name: Build with Gradle 17 | run: ./gradlew build 18 | -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/ResultStatisticsCalculator.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core 2 | 3 | class ResultStatisticsCalculator(val source: ByteArray, val result: ByteArray) { 4 | 5 | fun getSavedBytes(): Int { 6 | return source.size - result.size 7 | } 8 | 9 | fun getSavedRatio(): Double? { 10 | return (1.0 - result.size / source.size.toDouble()) 11 | } 12 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | 5 | dist: trusty 6 | 7 | before_install: 8 | - chmod +x gradlew 9 | 10 | after_success: 11 | - ./gradlew cobertura coveralls 12 | 13 | deploy: 14 | - provider: script 15 | script: ./gradlew bintrayUpload -PbintrayUser="${BINTRAY_USER}" -PbintrayKey="${BINTRAY_KEY}" -PdryRun=false 16 | skip_cleanup: true 17 | on: 18 | tags: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Eclipse 2 | .classpath 3 | .project 4 | .settings 5 | eclipsebin 6 | 7 | # Ant 8 | bin 9 | gen 10 | build 11 | out 12 | lib 13 | 14 | # Maven 15 | target 16 | pom.xml.* 17 | release.properties 18 | coverage.ec 19 | 20 | # IntelliJ 21 | .idea 22 | *.iml 23 | *.iws 24 | *.ipr 25 | classes 26 | gen-external-apklibs 27 | 28 | # Robolectric 29 | tmp 30 | 31 | .DS_Store 32 | 33 | # Gradle 34 | .gradle 35 | jniLibs 36 | build 37 | local.properties 38 | reports -------------------------------------------------------------------------------- /src/test/java/file/BinaryFileWriter.java: -------------------------------------------------------------------------------- 1 | package file; 2 | 3 | import java.io.*; 4 | 5 | public class BinaryFileWriter { 6 | public void write(byte[] input, String filePath) throws IOException { 7 | File directory = new File(filePath).getParentFile(); 8 | directory.mkdirs(); 9 | 10 | try (OutputStream output = new BufferedOutputStream(new FileOutputStream(filePath))) { 11 | output.write(input); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/file/BinaryFileReader.java: -------------------------------------------------------------------------------- 1 | package file; 2 | 3 | import com.fewlaps.slimjpg.core.util.InputStreamToByteArray; 4 | 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | 8 | public class BinaryFileReader { 9 | 10 | public byte[] load(String filePath) throws IOException { 11 | InputStream inputStream = getClass().getClassLoader().getResourceAsStream(filePath); 12 | 13 | return new InputStreamToByteArray().toByteArray(inputStream); 14 | } 15 | } -------------------------------------------------------------------------------- /src/test/java/MissingHuffmanCodeTableEntryTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.Test; 2 | 3 | public class MissingHuffmanCodeTableEntryTest extends BaseTest { 4 | 5 | @Test 6 | public void pictureWithHoffmanCodeIssues_keepingMetadata_shouldntThrowAnException() { 7 | test(WEBSITE, 134156, null, 0, IGNORE_MAX_WEIGHT, true); 8 | } 9 | 10 | @Test 11 | public void pictureWithHoffmanCodeIssues_deletingMetadata_shouldntThrowAnException() { 12 | test(WEBSITE, 128438, null, 0, IGNORE_MAX_WEIGHT, false); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/LosslessTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.Test; 2 | 3 | public class LosslessTest extends BaseTest { 4 | 5 | /** These tests also check that the way to know the JPEG quality 6 | */ 7 | 8 | @Test 9 | public void losslessOptimizations_inOptimizableFiles_shouldReturnANewFile() { 10 | test(CHINA, 308987, 0, 0, IGNORE_MAX_WEIGHT, true); 11 | } 12 | 13 | @Test 14 | public void losslessOptimizations_inUnoptimizableFiles_shouldReturnTheOriginalFile() { 15 | test(AVATAR, getWeight(AVATAR), 0, 0, IGNORE_MAX_WEIGHT, true); 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/util/InputStreamToByteArray.java: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core.util; 2 | 3 | import java.io.ByteArrayOutputStream; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | 7 | public class InputStreamToByteArray { 8 | 9 | public byte[] toByteArray(InputStream inputStream) throws IOException { 10 | ByteArrayOutputStream os = new ByteArrayOutputStream(); 11 | 12 | byte[] buffer = new byte[1024]; 13 | int len; 14 | 15 | while ((len = inputStream.read(buffer)) != -1) { 16 | os.write(buffer, 0, len); 17 | } 18 | 19 | return os.toByteArray(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/SlimJpg.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg 2 | 3 | import com.fewlaps.slimjpg.core.util.InputStreamToByteArray 4 | import java.io.File 5 | import java.io.FileInputStream 6 | import java.io.InputStream 7 | 8 | object SlimJpg { 9 | 10 | @JvmStatic 11 | fun file(image: ByteArray): RequestCreator { 12 | return RequestCreator(image) 13 | } 14 | 15 | @JvmStatic 16 | fun file(image: InputStream): RequestCreator { 17 | val bytes = InputStreamToByteArray().toByteArray(image) 18 | return file(bytes) 19 | } 20 | 21 | @JvmStatic 22 | fun file(image: File): RequestCreator { 23 | val inputStream = FileInputStream(image) 24 | return file(inputStream) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/JpegCheckerTest.java: -------------------------------------------------------------------------------- 1 | 2 | import com.fewlaps.slimjpg.core.util.JpegChecker; 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | 6 | import static junit.framework.TestCase.assertTrue; 7 | import static org.junit.Assert.assertFalse; 8 | 9 | public class JpegCheckerTest extends BaseTest { 10 | 11 | private JpegChecker checker; 12 | 13 | @Before 14 | public void setUp() { 15 | checker = new JpegChecker(); 16 | } 17 | 18 | @Test 19 | public void checkJpeg() { 20 | assertTrue(checker.isJpeg(getBytes(SIMCARDS))); 21 | assertTrue(checker.isJpeg(getBytes(WEBSITE))); 22 | assertTrue(checker.isJpeg(getBytes(VOLCANO))); 23 | } 24 | 25 | @Test 26 | public void checkPng() { 27 | assertFalse(checker.isJpeg(getBytes(SEA))); 28 | } 29 | 30 | @Test 31 | public void checkGif() { 32 | assertFalse(checker.isJpeg(getBytes(COLOMBIA))); 33 | } 34 | 35 | @Test 36 | public void checkBmp() { 37 | assertFalse(checker.isJpeg(getBytes(CHINA))); 38 | } 39 | } -------------------------------------------------------------------------------- /src/test/java/metadata/MetadataReader.java: -------------------------------------------------------------------------------- 1 | package metadata; 2 | 3 | import javax.imageio.ImageIO; 4 | import javax.imageio.ImageReader; 5 | import javax.imageio.metadata.IIOMetadata; 6 | import javax.imageio.stream.ImageInputStream; 7 | import java.io.ByteArrayInputStream; 8 | import java.io.IOException; 9 | import java.util.Iterator; 10 | 11 | public class MetadataReader { 12 | 13 | public IIOMetadata getMetadata(byte[] file) { 14 | ImageInputStream iis = null; 15 | try { 16 | iis = ImageIO.createImageInputStream(new ByteArrayInputStream(file)); 17 | } catch (IOException e) { 18 | throw new RuntimeException("Can't read the file bytes", e); 19 | } 20 | 21 | Iterator readers = ImageIO.getImageReaders(iis); 22 | ImageReader reader = readers.next(); 23 | reader.setInput(iis, false); 24 | try { 25 | return reader.getImageMetadata(0); 26 | } catch (IOException e) { 27 | throw new RuntimeException("Can't read the metadata", e); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/Result.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core 2 | 3 | import com.fewlaps.slimjpg.core.util.ReadableUtils 4 | 5 | data class Result( 6 | val picture: ByteArray, 7 | val elapsedTime: Long, 8 | val savedBytes: Long, 9 | val savedRatio: Double, 10 | val jpegQualityUsed: Int, 11 | val iterationsMade: Int, 12 | val internalError: Exception?) { 13 | 14 | override fun toString(): String { 15 | val sb = StringBuilder(); 16 | sb.append("Size: ${ReadableUtils.formatFileSize(picture.size.toLong())}\n") 17 | sb.append("Saved size: ${ReadableUtils.formatFileSize(savedBytes)}\n") 18 | sb.append("Saved ratio: ${ReadableUtils.formatPercentage(savedRatio)}\n") 19 | sb.append("JPEG quality used: ${ReadableUtils.formatPercentage(jpegQualityUsed)}\n") 20 | sb.append("Iterations made: $iterationsMade" + "\n") 21 | sb.append("Time: " + ReadableUtils.formatElapsedTime(elapsedTime) + "\n") 22 | sb.append("Error: " + (internalError ?: "No")) 23 | return sb.toString() 24 | } 25 | } -------------------------------------------------------------------------------- /src/test/java/JpegCompressorTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.core.util.JpegCompressor; 2 | import file.BinaryFileWriter; 3 | import org.junit.Test; 4 | 5 | import java.io.IOException; 6 | 7 | public class JpegCompressorTest extends BaseTest { 8 | 9 | private static final String OUT_DIRECTORY = "out/jpeg-qualities/"; 10 | 11 | @Test 12 | public void useAllJpegQualities() throws Exception { 13 | JpegCompressor compressor = new JpegCompressor(); 14 | byte[] image = getBytes(AVATAR); 15 | 16 | BinaryFileWriter writer = new BinaryFileWriter(); 17 | 18 | for (int quality = 0; quality <= 100; quality++) { 19 | try { 20 | long start = System.currentTimeMillis(); 21 | 22 | byte[] result = compressor.writeJpg(image, quality, false); 23 | 24 | long time = System.currentTimeMillis() - start; 25 | System.out.println("Compressing image in quality " + quality + " took " + time + "ms"); 26 | 27 | writer.write(result, OUT_DIRECTORY + "quality-" + quality + ".jpg"); 28 | } catch (IOException e) { 29 | throw new RuntimeException(e); 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/util/ReadableUtils.java: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core.util; 2 | 3 | import java.text.DecimalFormat; 4 | import java.util.concurrent.TimeUnit; 5 | 6 | public class ReadableUtils { 7 | 8 | public static String formatFileSize(long size) { 9 | if (size <= 0) return "0"; 10 | final String[] units = new String[]{"B", "kB", "MB", "GB", "TB"}; 11 | int digitGroups = (int) (Math.log10(size) / Math.log10(1024)); 12 | return new DecimalFormat("#,##0.0#").format(size / Math.pow(1024, digitGroups)) + "" + units[digitGroups]; 13 | } 14 | 15 | public static String formatPercentage(double rate) { 16 | return new DecimalFormat("#0.00%").format(rate); 17 | } 18 | 19 | public static String formatPercentage(int rate) { 20 | return rate + "%"; 21 | } 22 | 23 | public static String formatElapsedTime(final long l) { 24 | final long hr = TimeUnit.MILLISECONDS.toHours(l); 25 | final long min = TimeUnit.MILLISECONDS.toMinutes(l - TimeUnit.HOURS.toMillis(hr)); 26 | final long sec = TimeUnit.MILLISECONDS.toSeconds(l - TimeUnit.HOURS.toMillis(hr) - TimeUnit.MINUTES.toMillis(min)); 27 | final long ms = TimeUnit.MILLISECONDS.toMillis(l - TimeUnit.HOURS.toMillis(hr) - TimeUnit.MINUTES.toMillis(min) - TimeUnit.SECONDS.toMillis(sec)); 28 | return String.format("%02d:%02d:%02d.%03d", hr, min, sec, ms); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/MaxWeightTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.Test; 2 | 3 | public class MaxWeightTest extends BaseTest { 4 | 5 | /** 6 | * This picture has a very complex background. 7 | * It's a picture taken with an iPhone 6S. 8 | */ 9 | @Test 10 | public void testSimCards() { 11 | String file = SIMCARDS; 12 | int maxWeight = 300 * 1024; 13 | test(file, 301894, null, 0.5, maxWeight, true); 14 | test(file, 300705, null, 0.5, maxWeight, false); 15 | test(file, 301894, null, 1, maxWeight, true); 16 | test(file, 300705, null, 1, maxWeight, false); 17 | } 18 | 19 | /** 20 | * This picture is a real avatar used by a happy developer 21 | * It's a picture without metadata. 22 | */ 23 | @Test 24 | public void testAvatar() { 25 | String file = AVATAR; 26 | int maxWeight = 100 * 1024; 27 | test(file, 102307, null, 0.5, maxWeight, true); 28 | test(file, 101258, null, 0.5, maxWeight, false); 29 | test(file, 102307, null, 1, maxWeight, true); 30 | 31 | test(file, 672060, null, 0, maxWeight, false); //This image is huge because it saves the metadata 32 | test(file, 101258, null, 0.5, maxWeight, false); 33 | test(file, 101258, null, 1, maxWeight, false); 34 | test(file, 70952, null, 2, maxWeight, false); 35 | test(file, 47249, null, 3, maxWeight, false); 36 | test(file, 41877, null, 4, maxWeight, false); 37 | } 38 | } -------------------------------------------------------------------------------- /src/test/java/HardwareUseTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.SlimJpg; 2 | import org.junit.Ignore; 3 | import org.junit.Test; 4 | 5 | public class HardwareUseTest extends BaseTest { 6 | 7 | /** 8 | * This is a test to give us time to check how the JVM manages CPU and memory under heavy pressure 9 | * 10 | * We use VisualVM to have nice graphs of that CPU and memory usage 11 | */ 12 | @Test 13 | public void printMemoryUsage_inAnAggressiveUsage() { 14 | System.out.println("Memory used when the test starts: " + usedMemory() + " MB"); 15 | 16 | long startTime = System.currentTimeMillis(); 17 | 18 | long i = 0; 19 | long testTime = 1000 * 60 * 5; 20 | while ((startTime + testTime) > System.currentTimeMillis()) { 21 | for (byte[] picture : pictures) { 22 | doCommonCall(picture); 23 | } 24 | 25 | double usedMemoryInMB = usedMemory(); 26 | System.out.println("Iteration " + i++ + " using " + usedMemoryInMB + " MB"); 27 | } 28 | } 29 | 30 | private void doCommonCall(byte[] picture) { 31 | SlimJpg.file(picture) 32 | .maxVisualDiff(0.5) 33 | .maxFileWeightInKB(200) 34 | .useOptimizedMetadata() 35 | .optimize(); 36 | } 37 | 38 | private double usedMemory() { 39 | Runtime runtime = Runtime.getRuntime(); 40 | long totalMemory = runtime.totalMemory(); 41 | long freeMemory = runtime.freeMemory(); 42 | return (double) (totalMemory - freeMemory) / (double) (1024 * 1024); 43 | } 44 | } -------------------------------------------------------------------------------- /src/test/java/BuggyPicturesTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.SlimJpg; 2 | import com.fewlaps.slimjpg.core.Result; 3 | import org.junit.Test; 4 | 5 | import static junit.framework.TestCase.assertEquals; 6 | import static junit.framework.TestCase.assertNull; 7 | 8 | public class BuggyPicturesTest extends BaseTest { 9 | 10 | @Test 11 | public void picturesWith_BogusInputColorspace_shouldShareTheInternalException() { 12 | assertHasError("steam-bogus-input-colorspace.png", "Bogus input colorspace"); 13 | } 14 | 15 | @Test 16 | public void picturesWith_IllegalBandSize_shouldShareTheInternalException() { 17 | assertHasError("button-buggy-metadata-components.png", "Illegal band size: should be 0 < size <= 8"); 18 | } 19 | 20 | @Test 21 | public void picturesWith_MetadataComponentsIssue_shouldShareTheInternalException() { 22 | assertHasError("invalid-error-metadata-components.gif", "Metadata components != number of destination bands"); 23 | } 24 | 25 | @Test 26 | public void picturesWithJfifApp0MarkerIssue_shouldShareTheInternalException() { 27 | assertHasNotError("server-jfif-app0-marker.png"); 28 | } 29 | 30 | private void assertHasError(String picture, String errorMessage) { 31 | Result result = SlimJpg.file(getBytes(picture)).optimize(); 32 | Exception internalError = result.getInternalError(); 33 | assertEquals(errorMessage, internalError.getMessage()); 34 | } 35 | 36 | private void assertHasNotError(String picture) { 37 | Result result = SlimJpg.file(getBytes(picture)).optimize(); 38 | assertNull(result.getInternalError()); 39 | } 40 | } -------------------------------------------------------------------------------- /src/test/java/PngToJpegTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.SlimJpg; 2 | import com.fewlaps.slimjpg.core.Result; 3 | import file.BinaryFileWriter; 4 | import org.junit.Test; 5 | 6 | import java.io.IOException; 7 | 8 | import static org.junit.Assert.assertEquals; 9 | 10 | public class PngToJpegTest extends BaseTest { 11 | 12 | private static final String OUT_DIRECTORY = "out/png-to-jpg/"; 13 | 14 | @Test 15 | public void perfectPngConversion_deletingMetadata_shouldntBreak() { 16 | byte[] original = getBytes(LOGOTYPE); 17 | Result optimized = SlimJpg.file(original) 18 | .deleteMetadata() 19 | .optimize(); 20 | 21 | writeFiles(original, LOGOTYPE, optimized.getPicture(), "without-metadata", optimized.getJpegQualityUsed()); 22 | 23 | assertEquals(100, optimized.getJpegQualityUsed()); 24 | } 25 | 26 | @Test 27 | public void perfectPngConversion_keepingMetadata_shouldntBreak() { 28 | byte[] original = getBytes(LOGOTYPE); 29 | Result optimized = SlimJpg.file(original) 30 | .keepMetadata() 31 | .optimize(); 32 | 33 | writeFiles(original, LOGOTYPE, optimized.getPicture(), "with-metadata", optimized.getJpegQualityUsed()); 34 | 35 | assertEquals(100, optimized.getJpegQualityUsed()); 36 | } 37 | 38 | private void writeFiles(byte[] original, String name, byte[] optimized, String metadata, int quality) { 39 | BinaryFileWriter writer = new BinaryFileWriter(); 40 | 41 | try { 42 | writer.write(original, OUT_DIRECTORY + name); 43 | writer.write(optimized, OUT_DIRECTORY + name + "-" + metadata + "-" + quality + ".jpg"); 44 | } catch (IOException e) { 45 | throw new RuntimeException(e); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/test/java/OptimizeMetadataTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.core.JpegOptimizer; 2 | import org.junit.Ignore; 3 | import org.junit.Test; 4 | 5 | import java.io.IOException; 6 | 7 | import static junit.framework.TestCase.assertTrue; 8 | 9 | public class OptimizeMetadataTest extends BaseTest { 10 | 11 | private static final int REPETITIONS = 10; 12 | 13 | @Test 14 | @Ignore(value = "It doesn't pass on Travis... performance tests on CI machines are not that trusty") 15 | public void callOptimizeMetadata_isLessThanCallingBothMethods() throws IOException { 16 | for (int i = 0; i < REPETITIONS; i++) { 17 | for (byte[] picture : pictures) { 18 | assertOptimizedMetadata_takesLessTimeThanCallingBothLegacyMethods(picture); 19 | } 20 | } 21 | } 22 | 23 | private void assertOptimizedMetadata_takesLessTimeThanCallingBothLegacyMethods(byte[] original) throws IOException { 24 | long startTime; 25 | 26 | JpegOptimizer optimizer = new JpegOptimizer(); 27 | 28 | startTime = System.currentTimeMillis(); 29 | optimizer.optimize(original, 0.0, IGNORE_MAX_WEIGHT, true); 30 | optimizer.optimize(original, 0.0, IGNORE_MAX_WEIGHT, false); 31 | long totalTimeForLegacyMethods = System.currentTimeMillis() - startTime; 32 | 33 | startTime = System.currentTimeMillis(); 34 | optimizer.optimize(original, 0.0, IGNORE_MAX_WEIGHT); 35 | long totalTimeWithMetadataOptimization = System.currentTimeMillis() - startTime; 36 | 37 | System.out.println("Call two legacy methods: " + totalTimeForLegacyMethods + "ms"); 38 | System.out.println("Call new optimised method: " + totalTimeWithMetadataOptimization + "ms"); 39 | 40 | assertTrue(totalTimeForLegacyMethods > totalTimeWithMetadataOptimization); 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/RequestCreator.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg 2 | 3 | import com.fewlaps.slimjpg.core.JpegOptimizer 4 | import com.fewlaps.slimjpg.core.Result 5 | 6 | data class RequestCreator(val image: ByteArray, 7 | val maxVisualDiff: Double = 0.0, 8 | val maxFileWeight: Long = -1, 9 | val metadataPolicy: MetadataPolicy = MetadataPolicy.WHATEVER_GIVES_SMALLER_FILES) { 10 | 11 | fun optimize(): Result { 12 | if (metadataPolicy == MetadataPolicy.WHATEVER_GIVES_SMALLER_FILES) { 13 | return JpegOptimizer().optimize(image, maxVisualDiff, maxFileWeight) 14 | } else { 15 | return JpegOptimizer().optimize(image, maxVisualDiff, maxFileWeight, metadataPolicy == MetadataPolicy.KEEP_METADATA) 16 | } 17 | } 18 | 19 | fun maxVisualDiff(maxVisualDiff: Double): RequestCreator { 20 | return this.copy(maxVisualDiff = maxVisualDiff) 21 | } 22 | 23 | fun maxFileWeight(sizeInBytes: Long): RequestCreator { 24 | return this.copy(maxFileWeight = sizeInBytes) 25 | } 26 | 27 | fun maxFileWeightInKB(sizeInKiloBytes: Long): RequestCreator { 28 | return maxFileWeight(sizeInKiloBytes * 1024) 29 | } 30 | 31 | fun maxFileWeightInMB(sizeInMegaBytes: Double): RequestCreator { 32 | return maxFileWeight((sizeInMegaBytes * 1024 * 1024).toLong()) 33 | } 34 | 35 | fun keepMetadata(): RequestCreator { 36 | return this.copy(metadataPolicy = MetadataPolicy.KEEP_METADATA) 37 | } 38 | 39 | fun deleteMetadata(): RequestCreator { 40 | return this.copy(metadataPolicy = MetadataPolicy.DELETE_METADATA) 41 | } 42 | 43 | fun useOptimizedMetadata(): RequestCreator { 44 | return this.copy(metadataPolicy = MetadataPolicy.WHATEVER_GIVES_SMALLER_FILES) 45 | } 46 | 47 | enum class MetadataPolicy { 48 | KEEP_METADATA, 49 | DELETE_METADATA, 50 | WHATEVER_GIVES_SMALLER_FILES 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/metadata/MetadataParser.java: -------------------------------------------------------------------------------- 1 | package metadata; 2 | 3 | import org.w3c.dom.NamedNodeMap; 4 | import org.w3c.dom.Node; 5 | 6 | import javax.imageio.metadata.IIOMetadata; 7 | 8 | /** 9 | * This class is based in http://johnbokma.com/java/obtaining-image-metadata.html, 10 | * that is based in https://docs.oracle.com/javase/1.5.0/docs/guide/imageio/spec/apps.fm5.html 11 | *

12 | * Thanks for it, John! And well, good job, Oracle. Tell Sun guys I said hi. 13 | */ 14 | public class MetadataParser { 15 | 16 | public int countMarkersByName(IIOMetadata metadata, String nodeOrAttributeName) { 17 | int count = 0; 18 | 19 | String[] names = metadata.getMetadataFormatNames(); 20 | for (String name : names) { 21 | System.out.println("Format name: " + name); 22 | count += countMarkersByName(metadata.getAsTree(name), nodeOrAttributeName); 23 | } 24 | 25 | return count; 26 | } 27 | 28 | private int countMarkersByName(Node root, String nodeOrAttributeName) { 29 | return countMarkersByName(root, 0, nodeOrAttributeName); 30 | } 31 | 32 | private int countMarkersByName(Node node, int level, String nodeOrAttributeName) { 33 | int count = 0; 34 | 35 | if (node.getNodeName().equals(nodeOrAttributeName)) { 36 | count++; 37 | } 38 | 39 | System.out.print("<" + node.getNodeName()); 40 | NamedNodeMap map = node.getAttributes(); 41 | if (map != null) { // print attribute values 42 | int length = map.getLength(); 43 | for (int i = 0; i < length; i++) { 44 | Node attr = map.item(i); 45 | 46 | if (attr.getNodeName().equals(nodeOrAttributeName)) { 47 | count++; 48 | } 49 | } 50 | } 51 | 52 | Node child = node.getFirstChild(); 53 | if (child != null) { 54 | while (child != null) { // emit child tags recursively 55 | count += countMarkersByName(child, level + 1, nodeOrAttributeName); 56 | child = child.getNextSibling(); 57 | } 58 | } 59 | 60 | return count; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/java/metadata/MetadataDisplayer.java: -------------------------------------------------------------------------------- 1 | package metadata; 2 | 3 | import org.w3c.dom.NamedNodeMap; 4 | import org.w3c.dom.Node; 5 | 6 | import javax.imageio.metadata.IIOMetadata; 7 | 8 | /** 9 | * This class is almost a copy of http://johnbokma.com/java/obtaining-image-metadata.html, 10 | * that is based in https://docs.oracle.com/javase/1.5.0/docs/guide/imageio/spec/apps.fm5.html 11 | *

12 | * Thanks for it, John! And well, good job, Oracle. Tell Sun guys I said hi. 13 | */ 14 | public class MetadataDisplayer { 15 | 16 | public void displayMetadata(String title, IIOMetadata metadata) { 17 | System.out.println("\n-----------"); 18 | System.out.println(title.toUpperCase()); 19 | System.out.println("-----------\n"); 20 | 21 | String[] names = metadata.getMetadataFormatNames(); 22 | for (String name : names) { 23 | System.out.println("Format name: " + name); 24 | displayMetadata(metadata.getAsTree(name)); 25 | } 26 | } 27 | 28 | private void displayMetadata(Node root) { 29 | displayMetadata(root, 0); 30 | } 31 | 32 | private void displayMetadata(Node node, int level) { 33 | indent(level); // emit open tag 34 | System.out.print("<" + node.getNodeName()); 35 | NamedNodeMap map = node.getAttributes(); 36 | if (map != null) { // print attribute values 37 | int length = map.getLength(); 38 | for (int i = 0; i < length; i++) { 39 | Node attr = map.item(i); 40 | System.out.print(" " + attr.getNodeName() + 41 | "=\"" + attr.getNodeValue() + "\""); 42 | } 43 | } 44 | 45 | Node child = node.getFirstChild(); 46 | if (child != null) { 47 | System.out.println(">"); // close current tag 48 | while (child != null) { // emit child tags recursively 49 | displayMetadata(child, level + 1); 50 | child = child.getNextSibling(); 51 | } 52 | indent(level); // emit close tag 53 | System.out.println(""); 54 | } else { 55 | System.out.println("/>"); 56 | } 57 | } 58 | 59 | private void indent(int level) { 60 | for (int i = 0; i < level; i++) { 61 | System.out.print(" "); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/java/OptimizeAllImagesOnDisk.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.SlimJpg; 2 | import com.fewlaps.slimjpg.core.Result; 3 | import org.junit.Ignore; 4 | import org.junit.Test; 5 | 6 | import java.io.File; 7 | import java.util.Arrays; 8 | import java.util.List; 9 | 10 | public class OptimizeAllImagesOnDisk extends BaseTest { 11 | 12 | private List acceptedExtensions = Arrays.asList( 13 | ".jpg", 14 | ".png", 15 | ".gif", 16 | ".bmp" 17 | ); 18 | 19 | private List knownExceptions = Arrays.asList( 20 | "Bogus input colorspace", 21 | "Illegal band size: should be 0 < size <= 8", 22 | "Metadata components != number of destination bands", 23 | "JFIF APP0 must be first marker after SOI" 24 | ); 25 | 26 | @Test 27 | @Ignore(value = "It doesn't pass on Travis because it needs too much CPU time") 28 | public void optimizeAllPicturesOnDisk() { 29 | int foundPictures = extract("/"); 30 | System.out.println("Found " + foundPictures + " pictures"); 31 | } 32 | 33 | private int extract(String p) { 34 | int foundPictures = 0; 35 | 36 | File[] listFiles = new File(p).listFiles(); 37 | 38 | if (listFiles == null) { 39 | return foundPictures; 40 | } 41 | 42 | for (File x : listFiles) { 43 | if (x == null) { 44 | return foundPictures; 45 | } 46 | if (x.isHidden() || !x.canRead()) { 47 | continue; 48 | } 49 | if (x.isDirectory()) { 50 | foundPictures += extract(x.getPath()); 51 | } else { 52 | if (acceptedExtensions.contains(extension(x.getName()))) { 53 | Result result = SlimJpg.file(x).optimize(); 54 | if (result.getInternalError() != null) { 55 | if (!knownExceptions.contains(result.getInternalError().getMessage())) { 56 | System.out.println("Error while optimizing " + x.getPath()); 57 | result.getInternalError().printStackTrace(); 58 | } 59 | } 60 | foundPictures++; 61 | } 62 | } 63 | } 64 | 65 | return foundPictures; 66 | } 67 | 68 | private String extension(String name) { 69 | if (!name.contains(".")) { 70 | return ""; 71 | } 72 | return name.substring(name.lastIndexOf("."), name.length()); 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/util/JpegCompressor.java: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core.util; 2 | 3 | import javax.imageio.IIOImage; 4 | import javax.imageio.ImageIO; 5 | import javax.imageio.ImageReader; 6 | import javax.imageio.ImageWriter; 7 | import javax.imageio.metadata.IIOMetadata; 8 | import javax.imageio.plugins.jpeg.JPEGImageWriteParam; 9 | import javax.imageio.stream.ImageInputStream; 10 | import javax.imageio.stream.ImageOutputStream; 11 | import java.awt.image.BufferedImage; 12 | import java.io.ByteArrayInputStream; 13 | import java.io.ByteArrayOutputStream; 14 | import java.util.Iterator; 15 | 16 | import static javax.imageio.ImageWriteParam.MODE_EXPLICIT; 17 | 18 | public class JpegCompressor { 19 | 20 | private static final String JPG = "jpg"; 21 | 22 | public byte[] writeJpg(byte[] input, int quality, boolean keepMetadata) throws Exception { 23 | ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(input)); 24 | Iterator readers = ImageIO.getImageReaders(iis); 25 | ImageReader reader = readers.next(); 26 | reader.setInput(iis, false); 27 | 28 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 29 | ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream((outputStream)); 30 | 31 | final ImageWriter writer = ImageIO.getImageWritersByFormatName(JPG).next(); 32 | writer.setOutput(imageOutputStream); 33 | 34 | JPEGImageWriteParam writeParam = (JPEGImageWriteParam) writer.getDefaultWriteParam(); 35 | writeParam.setCompressionMode(MODE_EXPLICIT); 36 | 37 | float appliedQuality = quality / 100f; 38 | if (appliedQuality < 0) { 39 | writeParam.setCompressionQuality(0); 40 | } else { 41 | writeParam.setCompressionQuality(appliedQuality); 42 | } 43 | 44 | IIOMetadata metadata = null; 45 | if (keepMetadata) { 46 | metadata = reader.getImageMetadata(0); 47 | writeParam.setOptimizeHuffmanTables(true); 48 | } 49 | 50 | try { 51 | BufferedImage bufferedImage = reader.read(0); 52 | writer.write(null, new IIOImage(bufferedImage, null, metadata), writeParam); 53 | dispose(reader, writer); 54 | return outputStream.toByteArray(); 55 | } catch (Exception e) { 56 | dispose(reader, writer); 57 | throw e; 58 | } 59 | } 60 | 61 | private void dispose(ImageReader reader, ImageWriter writer) { 62 | writer.dispose(); 63 | reader.dispose(); 64 | } 65 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/util/BufferedImageComparator.java: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core.util; 2 | 3 | import java.awt.image.BufferedImage; 4 | import java.awt.image.DataBuffer; 5 | import java.io.IOException; 6 | 7 | public class BufferedImageComparator { 8 | 9 | /** 10 | * This is the third try of having a blatant fast RGB comparison 11 | * The author based it in the 8th Approach of 12 | * http://chriskirk.blogspot.fr/2011/01/performance-comparison-of-java2d-image.html 13 | *

14 | * As there are work to do with the last versions of JPEG, 15 | * we'll leave the authors' comments for the reader's sake 16 | * 17 | * @author collicalex https://github.com/collicalex/JPEGOptimizer 18 | */ 19 | 20 | public double getDifferencePercentage(BufferedImage img1, BufferedImage img2) throws IOException { 21 | checkSameSize(img1, img2); 22 | 23 | int width = img1.getWidth(null); 24 | int height = img1.getHeight(null); 25 | 26 | DataBuffer db1 = img1.getRaster().getDataBuffer(); 27 | DataBuffer db2 = img2.getRaster().getDataBuffer(); 28 | 29 | double diff = 0; 30 | int size = db1.getSize(); //size = width * height * 3 31 | double diffPercent = 0; 32 | 33 | //TODO: jpeg format v9 can use 12bit per channel, see: http://www.tomshardware.fr/articles/jpeg-lossless-12bit,1-46742.html 34 | 35 | if (size == (width * height * 3)) { //RGB 24bit per pixel - 3 bytes per pixel: 1 for R, 1 for G, 1 for B 36 | 37 | for (int i = 0; i < size; i += 3) { 38 | /* 39 | double deltaR = (db2.getElem(i) - db1.getElem(i)) / 255.; 40 | double deltaG = (db2.getElem(i+1) - db1.getElem(i+1)) / 255.; 41 | double deltaB = (db2.getElem(i+2) - db1.getElem(i+2)) / 255.; 42 | 43 | diff += Math.sqrt(Math.pow(deltaR, 2) + Math.pow(deltaG, 2) + Math.pow(deltaB, 2)); 44 | */ 45 | 46 | double deltaR = (db2.getElem(i) - db1.getElem(i)); 47 | double deltaG = (db2.getElem(i + 1) - db1.getElem(i + 1)); 48 | double deltaB = (db2.getElem(i + 2) - db1.getElem(i + 2)); 49 | 50 | diff += Math.sqrt(((deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB)) / 65025.); 51 | } 52 | 53 | double maxPixDiff = Math.sqrt(3); // max diff per color component is 1. So max diff on the 3 RGB component is 1+1+1. 54 | double n = width * height; 55 | diffPercent = diff / (n * maxPixDiff); 56 | 57 | } else if (size == (width * height)) { // Gray 8bit per pixel - Don't know if it's possible in jpeg, but just in case, code it! :) 58 | 59 | for (int i = 0; i < size; ++i) { 60 | diff += (db2.getElem(i) - db1.getElem(i)) / 255; 61 | } 62 | diffPercent = diff / size; 63 | } 64 | 65 | return diffPercent; 66 | } 67 | 68 | public boolean isSameContent(BufferedImage img1, BufferedImage img2) throws IOException { 69 | checkSameSize(img1, img2); 70 | 71 | DataBuffer db1 = img1.getRaster().getDataBuffer(); 72 | DataBuffer db2 = img2.getRaster().getDataBuffer(); 73 | 74 | int size = db1.getSize(); 75 | 76 | for (int i = 0; i < size; ++i) { 77 | if (db2.getElem(i) != db1.getElem(i)) { 78 | return false; 79 | } 80 | } 81 | return true; 82 | } 83 | 84 | private void checkSameSize(BufferedImage img1, BufferedImage img2) throws IOException { 85 | int width1 = img1.getWidth(null); 86 | int width2 = img2.getWidth(null); 87 | int height1 = img1.getHeight(null); 88 | int height2 = img2.getHeight(null); 89 | 90 | if ((width1 != width2) || (height1 != height2)) { 91 | throw new IOException("Images have different sizes"); 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/test/java/PublicApiTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.RequestCreator; 2 | import com.fewlaps.slimjpg.SlimJpg; 3 | import com.fewlaps.slimjpg.core.Result; 4 | import org.junit.Test; 5 | 6 | import java.io.ByteArrayInputStream; 7 | import java.io.File; 8 | import java.io.FileNotFoundException; 9 | import java.io.InputStream; 10 | 11 | import static com.fewlaps.slimjpg.core.util.ReadableUtils.*; 12 | import static org.junit.Assert.assertEquals; 13 | 14 | public class PublicApiTest extends BaseTest { 15 | 16 | @Test 17 | public void minimalCall_withAllPictures() { 18 | for (byte[] picture : pictures) { 19 | doMinimalCall(picture); 20 | } 21 | } 22 | 23 | @Test 24 | public void commonCall_withAllPictures() { 25 | for (byte[] picture : pictures) { 26 | doCommonCall(picture); 27 | } 28 | } 29 | 30 | @Test 31 | public void smallFileCall_avatar() { 32 | for (byte[] picture : pictures) { 33 | doSmallFileCall(picture); 34 | } 35 | } 36 | 37 | private void doMinimalCall(byte[] picture) { 38 | Result result = SlimJpg.file(picture) 39 | .optimize(); 40 | 41 | System.out.println("- Minimal call"); 42 | printOptimizationResult(picture, result); 43 | } 44 | 45 | private void doCommonCall(byte[] picture) { 46 | Result result = SlimJpg.file(picture) 47 | .maxVisualDiff(0.5) 48 | .maxFileWeightInKB(200) 49 | .useOptimizedMetadata() 50 | .optimize(); 51 | 52 | System.out.println("- Common call"); 53 | printOptimizationResult(picture, result); 54 | } 55 | 56 | private void doSmallFileCall(byte[] picture) { 57 | Result result = SlimJpg.file(picture) 58 | .maxVisualDiff(1) 59 | .maxFileWeightInKB(50) 60 | .useOptimizedMetadata() 61 | .optimize(); 62 | 63 | System.out.println("- Small file call"); 64 | printOptimizationResult(picture, result); 65 | } 66 | 67 | @Test 68 | public void byDefault_maxVisualDiff_isZero() { 69 | RequestCreator request = SlimJpg.file(getBytes(AVATAR)); 70 | double maxVisualDiff = request.getMaxVisualDiff(); 71 | assertEquals(0.0, maxVisualDiff, 0.1); 72 | } 73 | 74 | @Test 75 | public void byDefault_maxFileWeight_isNotApplied() { 76 | RequestCreator request = SlimJpg.file(getBytes(AVATAR)); 77 | long maxFileWeight = request.getMaxFileWeight(); 78 | assertEquals(-1, maxFileWeight); 79 | } 80 | 81 | @Test 82 | public void byDefault_metadata_isOptimized() { 83 | RequestCreator request = SlimJpg.file(getBytes(AVATAR)); 84 | RequestCreator.MetadataPolicy metadataPolicy = request.getMetadataPolicy(); 85 | assertEquals(metadataPolicy, RequestCreator.MetadataPolicy.WHATEVER_GIVES_SMALLER_FILES); 86 | } 87 | 88 | @Test 89 | public void inputCanBeAInputStream() { 90 | byte[] file = getBytes(AVATAR); 91 | InputStream inputStream = new ByteArrayInputStream(file); 92 | SlimJpg.file(inputStream).optimize(); 93 | } 94 | 95 | @Test 96 | public void inputCanBeAFile() { 97 | File input = new File("src/test/resources/avatar.jpg"); 98 | SlimJpg.file(input).optimize(); 99 | } 100 | 101 | @Test(expected = FileNotFoundException.class) 102 | public void expectFileNotFoundException_forUnknownFiles() { 103 | File input = new File("badpath.jpg"); 104 | SlimJpg.file(input).optimize(); 105 | } 106 | 107 | private void printOptimizationResult(byte[] file, Result result) { 108 | System.out.println("Original size: " + formatFileSize(file.length)); 109 | System.out.println("Optimized size: " + formatFileSize(result.getPicture().length)); 110 | System.out.println("Saved size: " + formatFileSize((result.getSavedBytes()))); 111 | System.out.println("Saved ratio: " + formatPercentage(result.getSavedRatio())); 112 | System.out.println("JPEG quality used: " + result.getJpegQualityUsed() + "%"); 113 | System.out.println("Time: " + formatElapsedTime(result.getElapsedTime())); 114 | System.out.println("Iterations: " + formatElapsedTime(result.getIterationsMade())); 115 | } 116 | } -------------------------------------------------------------------------------- /src/test/java/MaxDiffTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.Test; 2 | 3 | public class MaxDiffTest extends BaseTest { 4 | 5 | /** 6 | * This picture has a very complex background. 7 | * It's a picture taken with an iPhone 6S. 8 | */ 9 | @Test 10 | public void testSimCards() { 11 | String file = SIMCARDS; 12 | test(file, 1348751, null, 0.5, IGNORE_MAX_WEIGHT, true); 13 | test(file, 1406130, null, 0.5, IGNORE_MAX_WEIGHT, false); 14 | test(file, 480229, null, 1, IGNORE_MAX_WEIGHT, true); 15 | test(file, 586350, null, 1, IGNORE_MAX_WEIGHT, false); 16 | } 17 | 18 | /** 19 | * A blurry picture taken at night. 20 | * The picture doesn't have any metadata. 21 | */ 22 | @Test 23 | public void testEthiopiaVolcano() { 24 | String file = VOLCANO; 25 | test(file, 665642, null, 0.5, IGNORE_MAX_WEIGHT, true); 26 | test(file, 770116, null, 0.5, IGNORE_MAX_WEIGHT, false); 27 | test(file, 190608, null, 1, IGNORE_MAX_WEIGHT, true); 28 | test(file, 306115, null, 1, IGNORE_MAX_WEIGHT, false); 29 | } 30 | 31 | /** 32 | * This picture has a flat background with some shadows. It drives to JPEG artifacts. 33 | * The picture doesn't have any metadata. 34 | */ 35 | @Test 36 | public void testQuitNow() { 37 | String file = WEBSITE; 38 | test(file, 47355, null, 0.5, IGNORE_MAX_WEIGHT, true); 39 | test(file, 59115, null, 0.5, IGNORE_MAX_WEIGHT, false); 40 | test(file, 34726, null, 1, IGNORE_MAX_WEIGHT, true); 41 | test(file, 31805, null, 1, IGNORE_MAX_WEIGHT, false); 42 | } 43 | 44 | /** 45 | * A picture of the sea. 46 | * It's a .png file. 47 | */ 48 | @Test 49 | public void testPngFile() { 50 | String file = SEA; 51 | test(file, 539078, null, 0.5, IGNORE_MAX_WEIGHT, true); 52 | test(file, 219774, null, 0.5, IGNORE_MAX_WEIGHT, false); 53 | test(file, 313590, null, 1, IGNORE_MAX_WEIGHT, true); 54 | test(file, 120724, null, 1, IGNORE_MAX_WEIGHT, false); 55 | } 56 | 57 | /** 58 | * A picture of the northern Colombia beach. 59 | * It's a .gif file. 60 | */ 61 | @Test 62 | public void testGifFile() { 63 | String file = COLOMBIA; 64 | test(file, 1017511, null, 0.5, IGNORE_MAX_WEIGHT, true); //TODO: The resulting file is huge 65 | test(file, 471160, null, 0.5, IGNORE_MAX_WEIGHT, false); 66 | test(file, 819693, null, 1, IGNORE_MAX_WEIGHT, true); //TODO: The resulting file is huge 67 | test(file, 292446, null, 1, IGNORE_MAX_WEIGHT, false); 68 | } 69 | 70 | /** 71 | * This picture shows a stair in the nature. 72 | * It's a .bmp file! Hello Microsoft! 73 | */ 74 | @Test 75 | public void testBmpFile() { 76 | String file = CHINA; 77 | test(file, 308987, null, 0, IGNORE_MAX_WEIGHT, true); 78 | test(file, 376871, null, 0, IGNORE_MAX_WEIGHT, false); 79 | test(file, 308987, null, 0.5, IGNORE_MAX_WEIGHT, true); 80 | test(file, 376871, null, 0.5, IGNORE_MAX_WEIGHT, false); 81 | test(file, 217763, null, 1, IGNORE_MAX_WEIGHT, true); 82 | test(file, 235193, null, 1, IGNORE_MAX_WEIGHT, false); 83 | test(file, 139405, null, 2, IGNORE_MAX_WEIGHT, true); 84 | test(file, 141834, null, 2, IGNORE_MAX_WEIGHT, false); 85 | } 86 | 87 | /** 88 | * This picture is a real avatar used by a happy developer 89 | * It's a picture without metadata. 90 | */ 91 | @Test 92 | public void testAvatar() { 93 | String file = AVATAR; 94 | test(file, getWeight(file), null, 0, IGNORE_MAX_WEIGHT, true); 95 | 96 | test(file, 672060, null, 0, IGNORE_MAX_WEIGHT, false); 97 | test(file, 272426, null, 0.25, IGNORE_MAX_WEIGHT, false); 98 | test(file, 199736, null, 0.5, IGNORE_MAX_WEIGHT, false); 99 | test(file, 185095, null, 0.75, IGNORE_MAX_WEIGHT, false); 100 | test(file, 149648, null, 1, IGNORE_MAX_WEIGHT, false); 101 | test(file, 70952, null, 2, IGNORE_MAX_WEIGHT, false); 102 | test(file, 47249, null, 3, IGNORE_MAX_WEIGHT, false); 103 | test(file, 41877, null, 4, IGNORE_MAX_WEIGHT, false); 104 | } 105 | 106 | @Test 107 | public void optimizedPicturesCantWeightMoreThanOriginalOnes_keepingMetadata() { 108 | int maxVisualDiff = 0; 109 | test(SIMCARDS, getWeight(SIMCARDS), null, maxVisualDiff, IGNORE_MAX_WEIGHT, true); 110 | test(WEBSITE, 134156, null, maxVisualDiff, IGNORE_MAX_WEIGHT, true); 111 | } 112 | 113 | @Test 114 | public void optimizedPicturesCantWeightMoreThanOriginalOnes_deletingMetadata() { 115 | int maxVisualDiff = 0; 116 | test(SIMCARDS, 4456237, null, maxVisualDiff, IGNORE_MAX_WEIGHT, false); 117 | test(WEBSITE, 128438, null, maxVisualDiff, IGNORE_MAX_WEIGHT, false); 118 | } 119 | } -------------------------------------------------------------------------------- /src/test/java/IccProfileTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.SlimJpg; 2 | import com.fewlaps.slimjpg.core.Result; 3 | import com.fewlaps.slimjpg.core.util.BufferedImageComparator; 4 | import file.BinaryFileWriter; 5 | import org.junit.Test; 6 | 7 | import javax.imageio.ImageIO; 8 | import java.awt.image.BufferedImage; 9 | import java.io.ByteArrayInputStream; 10 | import java.io.IOException; 11 | 12 | import static org.junit.Assert.assertEquals; 13 | import static org.junit.Assert.assertNotEquals; 14 | 15 | public class IccProfileTest extends BaseTest { 16 | 17 | private static final String OUT_DIRECTORY = "out/icc-profiles/"; 18 | 19 | @Test 20 | public void saveJPGsWithDifferentIccProfiles_keepingMetadata() throws IOException { 21 | test(true); 22 | } 23 | 24 | @Test 25 | public void saveJPGsWithDifferentIccProfiles_deletingMetadata() throws IOException { 26 | test(false); 27 | } 28 | 29 | private void test(boolean keepMetadata) throws IOException { 30 | BinaryFileWriter writer = new BinaryFileWriter(); 31 | 32 | byte[] originalWithoutProfile = getBytes("daltonic-without-icc-profile.jpg"); 33 | byte[] originalSRGB = getBytes("daltonic-with-icc-profile-srgb.jpg"); 34 | byte[] originalAdobeRGB = getBytes("daltonic-with-icc-profile-adobergb1998.jpg"); 35 | 36 | Result optimizedWithoutProfile; 37 | Result optimizedSRGB; 38 | Result optimizedAdobeRGB; 39 | 40 | String fileSuffix = ""; 41 | if (keepMetadata) { 42 | fileSuffix = "-keep-metadata"; 43 | optimizedWithoutProfile = SlimJpg.file(originalWithoutProfile).keepMetadata().optimize(); 44 | optimizedSRGB = SlimJpg.file(originalSRGB).keepMetadata().optimize(); 45 | optimizedAdobeRGB = SlimJpg.file(originalAdobeRGB).keepMetadata().optimize(); 46 | } else { 47 | fileSuffix = "-delete-metadata"; 48 | optimizedWithoutProfile = SlimJpg.file(originalWithoutProfile).deleteMetadata().optimize(); 49 | optimizedSRGB = SlimJpg.file(originalSRGB).deleteMetadata().optimize(); 50 | optimizedAdobeRGB = SlimJpg.file(originalAdobeRGB).deleteMetadata().optimize(); 51 | } 52 | 53 | writer.write(originalWithoutProfile, OUT_DIRECTORY + "without-icc-profile-original.jpg"); 54 | writer.write(optimizedWithoutProfile.getPicture(), OUT_DIRECTORY + "without-icc-profile-optimized" + fileSuffix + ".jpg"); 55 | writer.write(originalSRGB, OUT_DIRECTORY + "srgb-original.jpg"); 56 | writer.write(optimizedSRGB.getPicture(), OUT_DIRECTORY + "srgb-optimized" + fileSuffix + ".jpg"); 57 | writer.write(originalAdobeRGB, OUT_DIRECTORY + "adobergb1998-original.jpg"); 58 | writer.write(optimizedAdobeRGB.getPicture(), OUT_DIRECTORY + "adobergb1998-optimized" + fileSuffix + ".jpg"); 59 | 60 | BufferedImageComparator comparator = new BufferedImageComparator(); 61 | BufferedImage originalWithoutProfileBI = ImageIO.read(new ByteArrayInputStream(originalWithoutProfile)); 62 | BufferedImage optimizedWithoutProfileBI = ImageIO.read(new ByteArrayInputStream(optimizedWithoutProfile.getPicture())); 63 | BufferedImage originalSRGBBI = ImageIO.read(new ByteArrayInputStream(originalSRGB)); 64 | BufferedImage optimizedSRGBBI = ImageIO.read(new ByteArrayInputStream(optimizedSRGB.getPicture())); 65 | BufferedImage originalAdobeRGBBI = ImageIO.read(new ByteArrayInputStream(originalAdobeRGB)); 66 | BufferedImage optimizedAdobeRGBBI = ImageIO.read(new ByteArrayInputStream(optimizedAdobeRGB.getPicture())); 67 | 68 | double differenceBetweenOriginalSRGBandWithoutICC = comparator.getDifferencePercentage(originalWithoutProfileBI, originalSRGBBI); 69 | double differenceBetweenOriginalSRGBandAdobeRGB = comparator.getDifferencePercentage(originalWithoutProfileBI, originalAdobeRGBBI); 70 | 71 | //The image without ICC Profile and the one with sRGB profile have the same content 72 | assertEquals(differenceBetweenOriginalSRGBandWithoutICC, 0, 0.0); 73 | //The image with sRGB profile and the Adobe RGB one are different 74 | assertNotEquals(differenceBetweenOriginalSRGBandAdobeRGB, 0, 0.0); 75 | 76 | double differenceWithoutProfile = comparator.getDifferencePercentage(originalWithoutProfileBI, optimizedWithoutProfileBI); 77 | double differenceSRGB = comparator.getDifferencePercentage(originalSRGBBI, optimizedSRGBBI); 78 | double differenceAdobeRGBBI = comparator.getDifferencePercentage(originalAdobeRGBBI, optimizedAdobeRGBBI); 79 | 80 | assertEquals(differenceWithoutProfile, differenceSRGB, 0.0); 81 | if (keepMetadata) { 82 | assertEquals(differenceWithoutProfile, differenceAdobeRGBBI, 0.0); 83 | } else { 84 | assertNotEquals(differenceWithoutProfile, differenceAdobeRGBBI, 0.0); 85 | } 86 | 87 | printCompressionResult(originalWithoutProfile, optimizedWithoutProfile); 88 | printCompressionResult(originalSRGB, optimizedSRGB); 89 | printCompressionResult(originalAdobeRGB, optimizedAdobeRGB); 90 | } 91 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /src/test/java/KeepMetadataTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.core.JpegOptimizer; 2 | import metadata.MetadataDisplayer; 3 | import metadata.MetadataParser; 4 | import metadata.MetadataReader; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import javax.imageio.metadata.IIOMetadata; 9 | import java.io.IOException; 10 | 11 | import static junit.framework.TestCase.assertEquals; 12 | import static org.junit.Assert.assertNotEquals; 13 | 14 | public class KeepMetadataTest extends BaseTest { 15 | 16 | public static final String UNKNOWN_MARKER = "unknown"; 17 | public static final String ADOBE_MARKER = "app14Adobe"; 18 | 19 | MetadataDisplayer metadataDisplayer; 20 | MetadataParser metadataParser; 21 | 22 | @Before 23 | public void setUp() { 24 | metadataDisplayer = new MetadataDisplayer(); 25 | metadataParser = new MetadataParser(); 26 | } 27 | 28 | @Test 29 | public void losslessOptimizations_inJpegsWithICCProfile_keepingMetadata_shouldKeepTheMetadata() throws IOException { 30 | byte[] original = getBytes(DALTONIC_WITH_ADOBE_ICC_PROFILE); 31 | byte[] optimized = new JpegOptimizer().optimize(original, 0.0, IGNORE_MAX_WEIGHT, true).getPicture(); 32 | assertSameMetadata(original, optimized); 33 | } 34 | 35 | @Test 36 | public void losslessOptimizations_inJpegsWithoutICCProfile_keepingMetadata_shouldKeepTheMetadata() throws IOException { 37 | byte[] original = getBytes(DALTONIC_WITHOUT_ICC_PROFILE); 38 | byte[] optimized = new JpegOptimizer().optimize(original, 0.0, IGNORE_MAX_WEIGHT, true).getPicture(); 39 | assertSameMetadata(original, optimized); 40 | } 41 | 42 | @Test 43 | public void losslessOptimizations_inJpegsWithICCProfile_deletingMetadata_shouldDeleteTheMetadata() throws IOException { 44 | byte[] original = getBytes(DALTONIC_WITH_ADOBE_ICC_PROFILE); 45 | byte[] optimized = new JpegOptimizer().optimize(original, 0.0, IGNORE_MAX_WEIGHT, false).getPicture(); 46 | assertNoMetadata(original, optimized); 47 | } 48 | 49 | @Test 50 | public void losslessOptimizations_inJpegsWithoutICCProfile_deletingMetadata_shouldDeleteTheMetadata() throws IOException { 51 | byte[] original = getBytes(DALTONIC_WITHOUT_ICC_PROFILE); 52 | byte[] optimized = new JpegOptimizer().optimize(original, 0.0, IGNORE_MAX_WEIGHT, false).getPicture(); 53 | assertNoMetadata(original, optimized); 54 | } 55 | 56 | @Test 57 | public void onePercentOptimizations_inJpegsWithICCProfile_keepingMetadata_shouldKeepTheMetadata() throws IOException { 58 | byte[] original = getBytes(DALTONIC_WITH_ADOBE_ICC_PROFILE); 59 | byte[] optimized = new JpegOptimizer().optimize(original, 1.0, IGNORE_MAX_WEIGHT, true).getPicture(); 60 | assertSameMetadata(original, optimized); 61 | } 62 | 63 | @Test 64 | public void onePercentOptimizations_inJpegsWithoutICCProfile_keepingMetadata_shouldKeepTheMetadata() throws IOException { 65 | byte[] original = getBytes(DALTONIC_WITHOUT_ICC_PROFILE); 66 | byte[] optimized = new JpegOptimizer().optimize(original, 1.0, IGNORE_MAX_WEIGHT, true).getPicture(); 67 | assertSameMetadata(original, optimized); 68 | } 69 | 70 | @Test 71 | public void onePercentOptimizations_inJpegsWithICCProfile_deletingMetadata_shouldDeleteTheMetadata() throws IOException { 72 | byte[] original = getBytes(DALTONIC_WITH_ADOBE_ICC_PROFILE); 73 | byte[] optimized = new JpegOptimizer().optimize(original, 1.0, IGNORE_MAX_WEIGHT, false).getPicture(); 74 | assertNoMetadata(original, optimized); 75 | } 76 | 77 | @Test 78 | public void onePercentOptimizations_inJpegsWithoutICCProfile_deletingMetadata_shouldDeleteTheMetadata() throws IOException { 79 | byte[] original = getBytes(DALTONIC_WITHOUT_ICC_PROFILE); 80 | byte[] optimized = new JpegOptimizer().optimize(original, 1.0, IGNORE_MAX_WEIGHT, false).getPicture(); 81 | assertNoMetadata(original, optimized); 82 | } 83 | 84 | private void assertSameMetadata(byte[] original, byte[] optimized) { 85 | MetadataReader metadataReader = new MetadataReader(); 86 | IIOMetadata originalMetadata = metadataReader.getMetadata(original); 87 | IIOMetadata optimizedMetadata = metadataReader.getMetadata(optimized); 88 | 89 | metadataDisplayer.displayMetadata("Original", originalMetadata); 90 | metadataDisplayer.displayMetadata("Optimized", optimizedMetadata); 91 | 92 | int unknownMarkersInOriginal = metadataParser.countMarkersByName(originalMetadata, UNKNOWN_MARKER); 93 | int unknownMarkersInOptimized = metadataParser.countMarkersByName(optimizedMetadata, UNKNOWN_MARKER); 94 | int adobeMarkersInOriginal = metadataParser.countMarkersByName(originalMetadata, ADOBE_MARKER); 95 | int adobeMarkersInOptimized = metadataParser.countMarkersByName(optimizedMetadata, ADOBE_MARKER); 96 | 97 | assertEquals(unknownMarkersInOriginal, unknownMarkersInOptimized); 98 | assertEquals(adobeMarkersInOriginal, adobeMarkersInOptimized); 99 | } 100 | 101 | private void assertNoMetadata(byte[] original, byte[] optimized) { 102 | MetadataReader metadataReader = new MetadataReader(); 103 | IIOMetadata originalMetadata = metadataReader.getMetadata(original); 104 | IIOMetadata optimizedMetadata = metadataReader.getMetadata(optimized); 105 | 106 | metadataDisplayer.displayMetadata("Original", originalMetadata); 107 | metadataDisplayer.displayMetadata("Optimized", optimizedMetadata); 108 | 109 | int countUnknownMarkersInOriginal = metadataParser.countMarkersByName(originalMetadata, UNKNOWN_MARKER); 110 | int countUnknownMarkersInOptimized = metadataParser.countMarkersByName(optimizedMetadata, UNKNOWN_MARKER); 111 | int adobeMarkersInOptimized = metadataParser.countMarkersByName(optimizedMetadata, ADOBE_MARKER); 112 | 113 | assertNotEquals(countUnknownMarkersInOriginal, countUnknownMarkersInOptimized); 114 | assertEquals(0, adobeMarkersInOptimized); 115 | } 116 | } -------------------------------------------------------------------------------- /src/test/java/BaseTest.java: -------------------------------------------------------------------------------- 1 | import com.fewlaps.slimjpg.core.JpegOptimizer; 2 | import com.fewlaps.slimjpg.core.Result; 3 | import com.fewlaps.slimjpg.core.util.BufferedImageComparator; 4 | import file.BinaryFileReader; 5 | import file.BinaryFileWriter; 6 | import org.jetbrains.annotations.NotNull; 7 | 8 | import javax.imageio.ImageIO; 9 | import java.awt.image.BufferedImage; 10 | import java.io.ByteArrayInputStream; 11 | import java.io.IOException; 12 | 13 | import static com.fewlaps.slimjpg.core.util.ReadableUtils.*; 14 | import static junit.framework.TestCase.assertEquals; 15 | import static junit.framework.TestCase.assertTrue; 16 | 17 | class BaseTest { 18 | 19 | static final String SIMCARDS = "simcards.jpg"; 20 | static final String WEBSITE = "website.jpg"; 21 | static final String VOLCANO = "volcano.jpg"; 22 | static final String AVATAR = "avatar.jpg"; // 1280 x 1280 // Unoptimizable 23 | static final String SEA = "sea.png"; 24 | static final String COLOMBIA = "colombia.gif"; 25 | static final String CHINA = "china.bmp"; // Optimizable 26 | static final String THAILAND = "thailand.jpg"; // 800 x 600 27 | static final String LOGOTYPE = "logotype.png"; // 1024 x 1024 28 | static final String DALTONIC_WITH_ADOBE_ICC_PROFILE = "daltonic-with-icc-profile-adobergb1998.jpg"; 29 | static final String DALTONIC_WITHOUT_ICC_PROFILE = "daltonic-without-icc-profile.jpg"; 30 | 31 | public static final int IGNORE_MAX_WEIGHT = -1; 32 | 33 | private static final String OUT_DIRECTORY = "out/images/"; 34 | 35 | public byte[][] pictures = new byte[][]{ 36 | getBytes(SIMCARDS), 37 | getBytes(WEBSITE), 38 | getBytes(VOLCANO), 39 | getBytes(AVATAR), 40 | getBytes(SEA), 41 | getBytes(COLOMBIA), 42 | getBytes(CHINA), 43 | getBytes(THAILAND), 44 | getBytes(LOGOTYPE), 45 | getBytes(DALTONIC_WITH_ADOBE_ICC_PROFILE), 46 | getBytes(DALTONIC_WITHOUT_ICC_PROFILE), 47 | }; 48 | 49 | void test(String picture, long expectedWeight, Integer expectedIterations, double maxVisualDiff, int maxWeight, boolean keepMetadata) { 50 | try { 51 | System.out.println("\n------------------\n\n- Request: "); 52 | System.out.println("Filename: " + picture); 53 | System.out.println("Max visual diff: " + maxVisualDiff); 54 | System.out.println("Max file weight: " + ((maxWeight < 0) ? "Not set" : formatFileSize(maxWeight))); 55 | System.out.println("Keep metadata: " + keepMetadata); 56 | 57 | byte[] original = getBytes(picture); 58 | JpegOptimizer slimmer = new JpegOptimizer(); 59 | 60 | Result optimized = slimmer.optimize(original, maxVisualDiff, maxWeight, keepMetadata); 61 | 62 | printCompressionResult(original, optimized); 63 | 64 | BinaryFileWriter writer = new BinaryFileWriter(); 65 | 66 | writer.write(original, OUT_DIRECTORY + picture); 67 | 68 | picture = picture.replaceAll(".jpg", ""); 69 | picture = picture.replaceAll(".gif", ""); 70 | picture = picture.replaceAll(".png", ""); 71 | 72 | writer.write(optimized.getPicture(), OUT_DIRECTORY + formatFileName(picture, maxVisualDiff, maxWeight, keepMetadata, optimized.getJpegQualityUsed())); 73 | 74 | assertEquals(expectedWeight, optimized.getPicture().length); 75 | 76 | if (optimized.getJpegQualityUsed() < 100 && keepMetadata) { 77 | BufferedImageComparator comparator = new BufferedImageComparator(); 78 | BufferedImage originalBI = ImageIO.read(new ByteArrayInputStream(original)); 79 | BufferedImage optimizedBI = ImageIO.read(new ByteArrayInputStream(optimized.getPicture())); 80 | double difference = comparator.getDifferencePercentage(originalBI, optimizedBI); 81 | assertTrue(difference < maxVisualDiff); 82 | } 83 | 84 | if (expectedIterations != null) { 85 | assertEquals((int) expectedIterations, optimized.getIterationsMade()); 86 | } 87 | } catch (IOException e) { 88 | throw new RuntimeException(e); 89 | } 90 | } 91 | 92 | protected void printCompressionResult(byte[] original, Result optimized) { 93 | System.out.println("\n- Original file:"); 94 | System.out.println("Size: " + formatFileSize(original.length)); 95 | System.out.println("\n- Optimization results:"); 96 | System.out.println("Size: " + formatFileSize((optimized.getPicture().length))); 97 | System.out.println("Saved size: " + formatFileSize((optimized.getSavedBytes()))); 98 | System.out.println("Saved ratio: " + formatPercentage(optimized.getSavedRatio())); 99 | System.out.println("JPEG quality used: " + optimized.getJpegQualityUsed() + "%"); 100 | System.out.println("Iterations made: " + optimized.getIterationsMade()); 101 | System.out.println("Time: " + formatElapsedTime(optimized.getElapsedTime())); 102 | 103 | try { 104 | BufferedImageComparator comparator = new BufferedImageComparator(); 105 | BufferedImage originalBI = ImageIO.read(new ByteArrayInputStream(original)); 106 | BufferedImage optimizedBI = ImageIO.read(new ByteArrayInputStream(optimized.getPicture())); 107 | System.out.println("Difference: " + comparator.getDifferencePercentage(originalBI, optimizedBI)); 108 | } catch (Exception ignored) { 109 | } 110 | } 111 | 112 | byte[] getBytes(String picture) { 113 | try { 114 | return new BinaryFileReader().load(picture); 115 | } catch (IOException e) { 116 | throw new RuntimeException(e); 117 | } 118 | } 119 | 120 | @NotNull 121 | private String formatFileName(String picture, double maxVisualDiff, double maxWeight, boolean keepMetadata, int jpegQualityUsed) { 122 | return picture + 123 | "-diff-" + maxVisualDiff + 124 | (maxWeight < 0 ? "" : "-weight-" + maxWeight) + 125 | "-metadata-" + keepMetadata + 126 | "-jpegQuality-" + jpegQualityUsed + 127 | ".jpg"; 128 | } 129 | 130 | protected int getWeight(String file) { 131 | return getBytes(file).length; 132 | } 133 | } -------------------------------------------------------------------------------- /src/main/java/com/fewlaps/slimjpg/core/JpegOptimizer.kt: -------------------------------------------------------------------------------- 1 | package com.fewlaps.slimjpg.core 2 | 3 | import com.fewlaps.slimjpg.core.util.BufferedImageComparator 4 | import com.fewlaps.slimjpg.core.util.JpegChecker 5 | import com.fewlaps.slimjpg.core.util.JpegCompressor 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.async 8 | import kotlinx.coroutines.runBlocking 9 | import java.awt.image.BufferedImage 10 | import java.io.ByteArrayInputStream 11 | import java.io.IOException 12 | import javax.imageio.IIOException 13 | import javax.imageio.ImageIO 14 | import kotlin.math.floor 15 | 16 | class JpegOptimizer { 17 | 18 | private val compressor: JpegCompressor = JpegCompressor() 19 | private val comparator: BufferedImageComparator = BufferedImageComparator() 20 | private val checker: JpegChecker = JpegChecker() 21 | 22 | @Throws(IOException::class) 23 | fun optimize(source: ByteArray, maxVisualDiff: Double, maxWeight: Long): Result { 24 | return runBlocking { 25 | val withMetadataAsync = optimizeAsync(source, maxVisualDiff, maxWeight, true) 26 | val withoutMetadataAsync = optimizeAsync(source, maxVisualDiff, maxWeight, false) 27 | 28 | val withMetadata = withMetadataAsync.await() 29 | val withoutMetadata = withoutMetadataAsync.await() 30 | 31 | return@runBlocking if (withMetadata.savedBytes > withoutMetadata.savedBytes) { 32 | withMetadata 33 | } else { 34 | withoutMetadata 35 | } 36 | } 37 | } 38 | 39 | fun optimizeAsync(source: ByteArray, maxVisualDiff: Double, maxWeight: Long, keepMetadata: Boolean) = GlobalScope.async { 40 | return@async optimize(source, maxVisualDiff, maxWeight, keepMetadata) 41 | } 42 | 43 | @Throws(IOException::class) 44 | fun optimize(source: ByteArray, maxVisualDiff: Double, maxWeight: Long, keepMetadata: Boolean): Result { 45 | require(!(maxVisualDiff < 0 || maxVisualDiff > 100)) { "maxVisualDiff should be a percentage between 0 and 100" } 46 | 47 | val start = System.currentTimeMillis() 48 | val (picture, jpegQualityUsed, iterationsMade, internalError) = getOptimizedPicture(source, maxVisualDiff, maxWeight, keepMetadata) 49 | val end = System.currentTimeMillis() 50 | 51 | val elapsedTime = end - start 52 | val calculator = ResultStatisticsCalculator(source, picture) 53 | return Result( 54 | picture, 55 | elapsedTime, 56 | calculator.getSavedBytes().toLong(), 57 | calculator.getSavedRatio()!!, 58 | jpegQualityUsed, 59 | iterationsMade, 60 | internalError 61 | ) 62 | } 63 | 64 | @Throws(IOException::class) 65 | private fun getOptimizedPicture(source: ByteArray, maxVisualDiff: Double, maxWeight: Long, keepMetadata: Boolean): InternalResult { 66 | try { 67 | var iterationsMade = 0 68 | 69 | val jpegSource = if (!checker.isJpeg(source)) { 70 | compressor.writeJpg(source, MAX_JPEG_QUALITY, keepMetadata) 71 | } else { 72 | source 73 | } 74 | 75 | val sourceBufferedImage = ImageIO.read(ByteArrayInputStream(jpegSource)) 76 | 77 | if (maxVisualDiff == 0.0) { 78 | val quality = 100 79 | var result = compressor.writeJpg(jpegSource, quality, keepMetadata) 80 | if (keepMetadata) { 81 | if (result.size > jpegSource.size) { 82 | result = jpegSource 83 | } 84 | } 85 | return InternalResult( 86 | result, 87 | quality, 88 | iterationsMade 89 | ) 90 | } 91 | 92 | var minQuality = MIN_JPEG_QUALITY 93 | var maxQuality = MAX_JPEG_QUALITY 94 | var quality = 0 95 | 96 | while (minQuality <= maxQuality) { 97 | quality = floor((minQuality + maxQuality) / 2.0).toInt() 98 | 99 | if (isThisQualityTooHigh(jpegSource, sourceBufferedImage, quality, maxVisualDiff, maxWeight, keepMetadata)) { 100 | maxQuality = quality - 1 101 | } else { 102 | minQuality = quality + 1 103 | } 104 | 105 | iterationsMade++ 106 | } 107 | 108 | var result: ByteArray 109 | if (quality < MAX_JPEG_QUALITY || !keepMetadata) { 110 | result = compressor.writeJpg(jpegSource, quality, keepMetadata) 111 | if (maxWeightIsDefined(maxWeight) && result.size > maxWeight && quality > 0) { 112 | quality -= 1 113 | result = compressor.writeJpg(jpegSource, quality, keepMetadata) 114 | } 115 | if (result.size > jpegSource.size && keepMetadata) { 116 | result = jpegSource 117 | quality = MAX_JPEG_QUALITY 118 | } 119 | } else { 120 | result = compressor.writeJpg(jpegSource, quality, keepMetadata) 121 | if (result.size > jpegSource.size) { 122 | result = jpegSource 123 | } 124 | } 125 | 126 | return InternalResult( 127 | result, 128 | quality, 129 | iterationsMade 130 | ) 131 | } catch (exception: Exception) { 132 | return InternalResult( 133 | source, 134 | 100, 135 | 0, 136 | exception 137 | ) 138 | } 139 | } 140 | 141 | @Throws(IOException::class) 142 | private fun isThisQualityTooHigh(source: ByteArray, sourceBufferedImage: BufferedImage, quality: Int, maxVisualDiffPorcentage: Double, maxWeight: Long, keepMetadata: Boolean): Boolean { 143 | val optimizedPicture = compressor.writeJpg(source, quality, keepMetadata) 144 | if (maxWeightIsDefined(maxWeight) && optimizedPicture.size > maxWeight) { 145 | return true 146 | } 147 | 148 | val bufferedOptimizedPicture = ImageIO.read(ByteArrayInputStream(optimizedPicture)) 149 | 150 | if (maxVisualDiffPorcentage == 0.0) { 151 | return comparator.isSameContent(sourceBufferedImage, bufferedOptimizedPicture) 152 | } else { 153 | var diff = comparator.getDifferencePercentage(sourceBufferedImage, bufferedOptimizedPicture) 154 | diff *= 100.0 155 | return diff < maxVisualDiffPorcentage 156 | } 157 | } 158 | 159 | private fun maxWeightIsDefined(maxWeight: Long): Boolean { 160 | return maxWeight > 0 161 | } 162 | 163 | companion object { 164 | private const val MIN_JPEG_QUALITY = 0 165 | private const val MAX_JPEG_QUALITY = 100 166 | } 167 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlimJPG 2 | 3 | This library will convert your JPG, PNG, GIF and BMP pictures to optimized JPG ones. 4 | 5 | The project started as a fork of [collicalex/JPEGOptimizer](https://github.com/collicalex/JPEGOptimizer), a desktop application to bulk optimize JPG files. 6 | 7 | 8 | ## Why is this library so great? 9 | 10 | Compressing pictures by the old way, just setting the JPEG compression quality when saving the file, leads to unknown visual quality loss. You can save a picture in 70% JPEG quality and still have a great picture while saving another one with the same 70% quality and get a very bad result. That's because the JPEG quality doesn't know anything about the result. In the other hand, SlimJPG does. 11 | 12 | Instead of setting the JPEG quality, SlimJPG receives a maximum visual difference. That's the porcentage of pixels that could vary from the original picture. So, if you set a 0% difference, you can still get an optimized image. Or, if you prefer to lose a bit of visual quality, like 0,5%, SlimJPG will compress your image as much as possible, just finding the best JPEG compression that matches your criteria. 13 | 14 | In addition, you can set a max file weight. You can request an optimized picture that doesn't weight more than 50kB. SlimJPG will give you the best picture that JPEG compression can. 15 | 16 | What about the picture's metadata? For example, if you're concerned with removing the user's geolocation, you can delete it from the resulting picture. In the other hand, if you prefer to keep the Color Profile from the original source, you can choose to keep it. In addition, if you don't mind to keep or to discard the metadata, SlimJPG will give you a picture with or without it, choosing the option that gives a smaller file. 17 | 18 | 19 | ## Ok, show me the code 20 | 21 | ```java 22 | // An almost lossless optimization of your image 23 | SlimJpg.file(byteArray).optimize(); 24 | SlimJpg.file(inputStream).optimize(); 25 | SlimJpg.file(file).optimize(); 26 | 27 | // Optimize your picture with a 0.5% of maximum visual loss 28 | SlimJpg.file(picture) 29 | .maxVisualDiff(0.5) 30 | .optimize(); 31 | 32 | // Optimize your picture to fit a 50kB file 33 | SlimJpg.file(picture) 34 | .maxFileWeightInKB(50) 35 | .optimize(); 36 | 37 | // An almost lossless optimization deleting the metadata 38 | SlimJpg.file(picture) 39 | .deleteMetadata() 40 | .optimize(); 41 | 42 | // An almost lossless optimization keeping the metadata 43 | SlimJpg.file(picture) 44 | .keepMetadata() 45 | .optimize(); 46 | 47 | // An almost lossless optimization choosing the optimised metadata value 48 | SlimJpg.file(picture) 49 | .useOptimizedMetadata() 50 | .optimize(); 51 | 52 | // Use the whole criteria the API offers 53 | SlimJpg.file(picture) 54 | .maxVisualDiff(0.5) 55 | .maxFileWeightInKB(50) 56 | .deleteMetadata() 57 | .optimize(); 58 | ``` 59 | 60 | 61 | ## Give me some numbers! 62 | 63 | Of course! These computations have been made in a MacBook PRO 13" 2015 with only two warm cores. 64 | 65 | **A perfect copy:** Optimizing a picture without losing anything: 66 | 67 | |Resolution|Original size|Saved|JPEG Quality|Took| 68 | |:---:|:---:|:---:|:---:|:---:| 69 | |1280x1280|206.98kB|2.51kB (1.21%)|79%|2,381ms| 70 | |800x600|525.95kB|301.78kB (57.38%)|93%|2,845ms| 71 | 72 | **A common optimization:** Optimizing a picture losing 0.5% with a 200kB max file size, deleting the metadata: 73 | 74 | |Resolution|Original size|Saved|JPEG Quality|Took| 75 | |:---:|:---:|:---:|:---:|:---:| 76 | |1280x1280|206.98kB|11.93kB (5.76%)|74%|1,223ms| 77 | |800x600|525.95kB|326.76kB (62.13%)|91%|678ms| 78 | 79 | **A very small file:** Optimizing a picture losing 1% with a 50kB max file size, keeping the metadata: 80 | 81 | |Resolution|Original size|Saved|JPEG Quality|Took| 82 | |:---:|:---:|:---:|:---:|:---:| 83 | |1280x1280|206.98kB|157.96kB (76.32%)|10%*|1,356ms| 84 | |800x600|525.95kB|476.20kB (90.54%)|25%*|504ms| 85 | 86 | *You'll notice the last test used very low JPEG quality. That's because the file size was too small, so it used the highest quality that gave <50kB files. 87 | 88 | 89 | ## May I use it Android? 90 | 91 | Nope. SlimJPG targets the rocky server-side guys. The library uses `javax.imageio.ImageIO` that is not included in Android. D'oh! If you try it you'll end with a `java.lang.NoClassDefFoundError: Failed resolution of: Ljavax/imageio/ImageIO;`. It was harder for me than for you... 92 | 93 | ## How does SlimJPG manage the color profiles? 94 | 95 | The embedded color profile of a JPG is stored in its metadata. So, if you have a JPG with an embedded color profile and you need to have it embedded also in the optimized JPG, tell the library to keep the picture's metadata. This is usually needed when you intend to print that optimized JPG on paper or fabric, like the photographs or the fashion designers do. If you plan to display your images on computer or phone screens, usually you don't need to keep the color profiles. 96 | 97 | In case you're managing JPGs with an embedded color profile and you delete its metadata, the resulting JPG will have no color profile, so the default sRGB will be used. That's what the JPG standard says. But don't worry: if the source picture had another color profile, let's say Adobe RGB 1998, SlimJPG will convert those colors to sRGB and save the picture without a color profile, so the browser or app that displays the JPG will successfully use sRGB. 98 | 99 | ## Can I try it online? 100 | 101 | Of course! Check https://slimjpg.herokuapp.com and upload some pictures. Right now, it gives pictures of 50KB and a 1% of difference, so expect very compressed files. Someday, the webpage will let you choose the same than SlimJPG does: the max visual difference, max file weight and the metadata policy. And a fancy way to compare the source picture and the result. But in the meantime, the page is so humble. 102 | 103 | 104 | # Download 105 | 106 | * Gradle: 107 | ```groovy 108 | repositories { jcenter() } 109 | 110 | compile 'com.fewlaps.slimjpg:slimjpg:1.3.3' 111 | ``` 112 | * Maven: 113 | ```xml 114 | 115 | fewlaps 116 | https://dl.bintray.com/fewlaps/maven 117 | 118 | 119 | 120 | com.fewlaps.slimjpg 121 | slimjpg 122 | 1.3.3 123 | 124 | ``` 125 | 126 | ## License 127 | 128 | Copyright (c) 2019 Fewlaps (https://github.com/fewlaps) & Alex (https://github.com/collicalex) 129 | 130 | Permission is hereby granted, free of charge, to any person obtaining a copy 131 | of this software and associated documentation files (the "Software"), to deal 132 | in the Software without restriction, including without limitation the rights 133 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 134 | copies of the Software, and to permit persons to whom the Software is 135 | furnished to do so, subject to the following conditions: 136 | 137 | The above copyright notice and this permission notice shall be included in all 138 | copies or substantial portions of the Software. 139 | 140 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 141 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 142 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 143 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 144 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 145 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 146 | SOFTWARE. 147 | --------------------------------------------------------------------------------