From 5f97f1b0e1d293dd7757bc36771f943b4d4b7297 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 10 Jun 2023 20:11:32 -0500 Subject: [PATCH] Fix PNG image compression PNG image compression (#268) --- gradle/wrapper/gradle-wrapper.properties | 2 +- .../com/sksamuel/scrimage/nio/PngWriter.java | 138 ++++++++---------- scrimage-tests/build.gradle.kts | 1 - .../sksamuel/scrimage/core/Issue267Test.kt | 20 +++ .../src/test/resources/issue267.png | Bin 0 -> 10036 bytes 5 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 scrimage-tests/src/test/kotlin/com/sksamuel/scrimage/core/Issue267Test.kt create mode 100644 scrimage-tests/src/test/resources/issue267.png diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 070cb702..fae08049 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/scrimage-core/src/main/java/com/sksamuel/scrimage/nio/PngWriter.java b/scrimage-core/src/main/java/com/sksamuel/scrimage/nio/PngWriter.java index 1fd95bef..b11547da 100644 --- a/scrimage-core/src/main/java/com/sksamuel/scrimage/nio/PngWriter.java +++ b/scrimage-core/src/main/java/com/sksamuel/scrimage/nio/PngWriter.java @@ -16,88 +16,76 @@ package com.sksamuel.scrimage.nio; -import ar.com.hjg.pngj.FilterType; -import ar.com.hjg.pngj.ImageInfo; -import ar.com.hjg.pngj.ImageLineInt; import com.sksamuel.scrimage.AwtImage; import com.sksamuel.scrimage.metadata.ImageMetadata; +import javax.imageio.IIOImage; import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.awt.image.DataBufferInt; -import java.awt.image.SinglePixelPackedSampleModel; +import javax.imageio.ImageTypeSpecifier; +import javax.imageio.ImageWriteParam; +import javax.imageio.stream.ImageOutputStream; import java.io.IOException; import java.io.OutputStream; public class PngWriter implements ImageWriter { - public static final PngWriter MaxCompression = new PngWriter(9); - public static final PngWriter MinCompression = new PngWriter(1); - public static final PngWriter NoCompression = new PngWriter(0); - - private final int compressionLevel; - - public PngWriter() { - this.compressionLevel = 9; - } - - public PngWriter(int compressionLevel) { - this.compressionLevel = compressionLevel; - } - - // require(compressionLevel >= 0 && compressionLevel < 10, "Compression level must be between 0 (none) and 9 (max)") - - public PngWriter withMaxCompression() { - return MaxCompression; - } - - public PngWriter withMinCompression() { - return MinCompression; - } - - public PngWriter withCompression(int compression) { - return new PngWriter(compression); - } - - @Override - public void write(AwtImage image, ImageMetadata metadata, OutputStream out) throws IOException { - - if (image.awt().getType() == BufferedImage.TYPE_INT_ARGB) { - - ImageInfo imi = new ImageInfo(image.width, image.height, 8, true); - - ar.com.hjg.pngj.PngWriter writer = new ar.com.hjg.pngj.PngWriter(out, imi); - writer.setCompLevel(compressionLevel); - writer.setFilterType(FilterType.FILTER_DEFAULT); - - DataBufferInt db = (DataBufferInt) image.awt().getRaster().getDataBuffer(); - if (db.getNumBanks() != 1) throw new RuntimeException("This method expects one bank"); - - SinglePixelPackedSampleModel samplemodel = (SinglePixelPackedSampleModel) image.awt().getSampleModel(); - ImageLineInt line = new ImageLineInt(imi); - int[] dbbuf = db.getData(); - - for (int row = 0; row < imi.rows; row++) { - int elem = samplemodel.getOffset(0, row); - int j = 0; - for (int col = 0; col < imi.cols; col++) { - int sample = dbbuf[elem]; - elem = elem + 1; - line.getScanline()[j] = (sample & 0xFF0000) >> 16; // R - j = j + 1; - line.getScanline()[j] = (sample & 0xFF00) >> 8; // G - j = j + 1; - line.getScanline()[j] = sample & 0xFF; // B - j = j + 1; - line.getScanline()[j] = ((sample & 0xFF000000) >> 24) & 0xFF; // A - j = j + 1; - } - writer.writeRow(line, row); - } - writer.end();// end calls close - - } else { - ImageIO.write(image.awt(), "png", out); - } - } + public static final PngWriter MaxCompression = new PngWriter(9); + public static final PngWriter MinCompression = new PngWriter(1); + public static final PngWriter NoCompression = new PngWriter(0); + + private final int compressionLevel; + + public PngWriter() { + this.compressionLevel = 9; + } + + public PngWriter(int compressionLevel) { + this.compressionLevel = compressionLevel; + } + + // require(compressionLevel >= 0 && compressionLevel < 10, "Compression level must be between 0 (none) and 9 (max)") + + public PngWriter withMaxCompression() { + return MaxCompression; + } + + public PngWriter withMinCompression() { + return MinCompression; + } + + public PngWriter withCompression(int compression) { + return new PngWriter(compression); + } + + @Override + public void write(AwtImage image, ImageMetadata metadata, OutputStream out) throws IOException { + + ImageTypeSpecifier type = ImageTypeSpecifier.createFromBufferedImageType(image.getType()); + javax.imageio.ImageWriter writer = ImageIO.getImageWriters(type, "png").next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + + if (param.canWriteCompressed()) { + switch (compressionLevel) { + case 9: // max + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(0.0f); + break; + case 1: // min + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(1.0f); + break; + case 0: // none + param.setCompressionMode(ImageWriteParam.MODE_DISABLED); + default: + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(compressionLevel / 10f); + } + } + + ImageOutputStream ios = ImageIO.createImageOutputStream(out); + writer.setOutput(ios); + writer.write(null, new IIOImage(image.awt(), null, null), param); + writer.dispose(); + ios.close(); + } } diff --git a/scrimage-tests/build.gradle.kts b/scrimage-tests/build.gradle.kts index b0238fab..7829282b 100644 --- a/scrimage-tests/build.gradle.kts +++ b/scrimage-tests/build.gradle.kts @@ -9,7 +9,6 @@ dependencies { implementation("com.drewnoakes:metadata-extractor:2.18.0") implementation("com.github.zh79325:open-gif:1.0.4") implementation("commons-io:commons-io:2.11.0") - implementation("ar.com.hjg:pngj:2.1.0") implementation(project(":scrimage-core")) testImplementation(kotlin("stdlib")) testImplementation(kotlin("stdlib-jdk8")) diff --git a/scrimage-tests/src/test/kotlin/com/sksamuel/scrimage/core/Issue267Test.kt b/scrimage-tests/src/test/kotlin/com/sksamuel/scrimage/core/Issue267Test.kt new file mode 100644 index 00000000..86d2d71a --- /dev/null +++ b/scrimage-tests/src/test/kotlin/com/sksamuel/scrimage/core/Issue267Test.kt @@ -0,0 +1,20 @@ +package com.sksamuel.scrimage.core + +import com.sksamuel.scrimage.ImmutableImage +import com.sksamuel.scrimage.nio.PngWriter +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeLessThan +import java.awt.image.BufferedImage + +class Issue267Test : FunSpec() { + init { + test("png compression for all types") { + val originalImage = ImmutableImage.loader().fromResource("/issue267.png") + originalImage.copy(BufferedImage.TYPE_4BYTE_ABGR).bytes(PngWriter.MaxCompression).size shouldBeLessThan 15000 + originalImage.copy(BufferedImage.TYPE_3BYTE_BGR).bytes(PngWriter.MaxCompression).size shouldBeLessThan 15000 + originalImage.copy(BufferedImage.TYPE_INT_ARGB).bytes(PngWriter.MaxCompression).size shouldBeLessThan 15000 + originalImage.copy(BufferedImage.TYPE_INT_BGR).bytes(PngWriter.MaxCompression).size shouldBeLessThan 15000 + originalImage.copy(BufferedImage.TYPE_INT_RGB).bytes(PngWriter.MaxCompression).size shouldBeLessThan 15000 + } + } +} diff --git a/scrimage-tests/src/test/resources/issue267.png b/scrimage-tests/src/test/resources/issue267.png new file mode 100644 index 0000000000000000000000000000000000000000..a74c5823a703120247917d77a85440225bec3932 GIT binary patch literal 10036 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 literal 0 HcmV?d00001