diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b0ea7be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,230 @@
+# IntelliJ project file
+*.iml
+
+# Java Flight Recorder
+*.jfr
+
+#################### Language ####################
+# Language general ignores.
+
+### Maven
+# https://github.com/github/gitignore/blob/master/Maven.gitignore
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+
+#################### IDE ####################
+# IDE general ignores.
+
+### Eclipse
+# https://github.com/github/gitignore/blob/master/Global/Eclipse.gitignore
+
+.metadata
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
+.recommenders
+
+# Eclipse Core
+.project
+
+# External tool builders
+.externalToolBuilders/
+
+# Locally stored "Eclipse launch configurations"
+*.launch
+
+# PyDev specific (Python IDE for Eclipse)
+*.pydevproject
+
+# CDT-specific (C/C++ Development Tooling)
+.cproject
+
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+# Java annotation processor (APT)
+.factorypath
+
+# PDT-specific (PHP Development Tools)
+.buildpath
+
+# sbteclipse plugin
+.target
+
+# Tern plugin
+.tern-project
+
+# TeXlipse plugin
+.texlipse
+
+# STS (Spring Tool Suite)
+.springBeans
+
+# Code Recommenders
+.recommenders/
+
+### JetBrains
+# https://github.com/github/gitignore/blob/master/Global/JetBrains.gitignore
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+
+# Sensitive or high-churn files:
+.idea/dataSources.ids
+.idea/dataSources.xml
+.idea/dataSources.local.xml
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+
+# Gradle:
+.idea/gradle.xml
+.idea/libraries
+
+# Mongo Explorer plugin:
+.idea/mongoSettings.xml
+
+## File-based project format:
+*.iws
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### SublimeText
+# https://github.com/github/gitignore/blob/master/Global/SublimeText.gitignore
+# cache files for sublime text
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+
+# workspace files are user-specific
+*.sublime-workspace
+
+# project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using SublimeText
+# *.sublime-project
+
+# sftp configuration file
+sftp-config.json
+
+# Package control specific files
+Package Control.last-run
+Package Control.ca-list
+Package Control.ca-bundle
+Package Control.system-ca-bundle
+Package Control.cache/
+Package Control.ca-certs/
+bh_unicode_properties.cache
+
+# Sublime-github package stores a github token in this file
+# https://packagecontrol.io/packages/sublime-github
+GitHub.sublime-settings
+
+#################### OS ####################
+# Operating system general ignores.
+
+### https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+
+*.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### https://github.com/github/gitignore/blob/master/Global/Linux.gitignore
+
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+### https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+
+# Windows image file caches
+Thumbs.db
+ehthumbs.db
+
+# Folder config file
+Desktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+#################### OTHER ####################
+# Other general ignores.
+
+### Dropbox
+# https://github.com/github/gitignore/blob/master/Global/Dropbox.gitignore
+# Dropbox settings and caches
+.dropbox
+.dropbox.attr
+.dropbox.cache
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..68ea663
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,11 @@
+sudo: false
+language: java
+script: mvn clean verify -P coverage
+jdk:
+ - openjdk8
+after_success:
+ - bash <(curl -s https://codecov.io/bash)
+cache:
+ timeout: 1000
+ directories:
+ - $HOME/.m2
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..02f054f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Olof Larsson and Johan Tidén
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/__pom.xml__ b/__pom.xml__
new file mode 100644
index 0000000..48f6690
--- /dev/null
+++ b/__pom.xml__
@@ -0,0 +1,187 @@
+
+
+ 4.0.0
+
+ com.pngencoder
+ pngencoder
+ 0.12.0-SNAPSHOT
+ jar
+
+ ${project.groupId}:${project.artifactId}
+ A really fast encoder for PNG images.
+ https://github.com/pngencoder/pngencoder
+ 2020
+
+
+
+ MIT License
+ http://www.opensource.org/licenses/mit-license.php
+
+
+
+
+
+ Olof Larsson
+ olof.larsson@looklet.com
+ Looklet
+ https://looklet.com
+
+
+ Johan Tidén
+ johan.tiden@looklet.com
+ Looklet
+ https://looklet.com
+
+
+ Johan Kaving
+ johan.kaving@looklet.com
+ Looklet
+ https://looklet.com
+
+
+ Raul Jimenez
+ raul.jimenez@looklet.com
+ Looklet
+ https://looklet.com
+
+
+
+
+ scm:git:git://github.com/pngencoder/pngencoder.git
+ scm:git:ssh://github.com:pngencoder/pngencoder.git
+ http://github.com/pngencoder/pngencoder
+
+
+
+
+ ossrh
+ https://oss.sonatype.org/content/repositories/snapshots
+
+
+ ossrh
+ https://oss.sonatype.org/service/local/staging/deploy/maven2/
+
+
+
+
+ UTF-8
+ 8
+ 8
+ true
+ true
+ true
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.2.0
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.2.1
+
+
+ attach-sources
+ verify
+
+ jar-no-fork
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 2.22.2
+
+
+
+
+
+
+ sign
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 1.6
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+
+
+
+
+
+
+
+ coverage
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.5
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+
+
+
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.7.0
+ test
+
+
+ org.hamcrest
+ hamcrest
+ 2.2
+ test
+
+
+ org.openjdk.jmh
+ jmh-generator-annprocess
+ 1.23
+ test
+
+
+
+
diff --git a/src/module-info.java b/src/module-info.java
new file mode 100644
index 0000000..23bb83a
--- /dev/null
+++ b/src/module-info.java
@@ -0,0 +1,6 @@
+module org.atriasoft.pngencoder {
+ exports org.atriasoft.pngencoder;
+
+ requires transitive org.atriasoft.egami;
+ requires transitive org.atriasoft.etk;
+}
\ No newline at end of file
diff --git a/src/org/atriasoft/pngencoder/PngEncoder.java b/src/org/atriasoft/pngencoder/PngEncoder.java
new file mode 100644
index 0000000..bffed89
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoder.java
@@ -0,0 +1,173 @@
+package org.atriasoft.pngencoder;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.zip.Deflater;
+
+import org.atriasoft.egami.Image;
+
+/**
+ * Main class, containing the interface for PngEncoder.
+ * PngEncoder is a really fast encoder for PNG images in Java.
+ */
+public class PngEncoder {
+ /**
+ * Compression level 9 is the default.
+ * It produces images with a size comparable to ImageIO.
+ */
+ public static int DEFAULT_COMPRESSION_LEVEL = Deflater.BEST_COMPRESSION;
+
+ private final Image bufferedImage;
+ private final int compressionLevel;
+ private final boolean multiThreadedCompressionEnabled;
+ private final PngEncoderPhysicalPixelDimensions physicalPixelDimensions;
+ private final PngEncoderSrgbRenderingIntent srgbRenderingIntent;
+
+ /**
+ * Constructs an empty PngEncoder. Usually combined with methods named with*.
+ */
+ public PngEncoder() {
+ this(null, PngEncoder.DEFAULT_COMPRESSION_LEVEL, true, null, null);
+ }
+
+ private PngEncoder(final Image bufferedImage, final int compressionLevel, final boolean multiThreadedCompressionEnabled, final PngEncoderSrgbRenderingIntent srgbRenderingIntent,
+ final PngEncoderPhysicalPixelDimensions physicalPixelDimensions) {
+ this.bufferedImage = bufferedImage;
+ this.compressionLevel = PngEncoderVerificationUtil.verifyCompressionLevel(compressionLevel);
+ this.multiThreadedCompressionEnabled = multiThreadedCompressionEnabled;
+ this.srgbRenderingIntent = srgbRenderingIntent;
+ this.physicalPixelDimensions = physicalPixelDimensions;
+ }
+
+ public Image getBufferedImage() {
+ return this.bufferedImage;
+ }
+
+ public int getCompressionLevel() {
+ return this.compressionLevel;
+ }
+
+ public PngEncoderSrgbRenderingIntent getSrgbRenderingIntent() {
+ return this.srgbRenderingIntent;
+ }
+
+ public boolean isMultiThreadedCompressionEnabled() {
+ return this.multiThreadedCompressionEnabled;
+ }
+
+ /**
+ * Encodes the image and returns data as {@code byte[]}.
+ * @throws NullPointerException if the image has not been set.
+ * @return encoded data
+ */
+ public byte[] toBytes() {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream(64 * 1024);
+ toStream(outputStream);
+ return outputStream.toByteArray();
+ }
+
+ /**
+ * Encodes the image and saves data into {@code file}.
+ * @param file destination file where the encoded data will be written
+ * @throws NullPointerException if the image has not been set.
+ * @throws UncheckedIOException instead of IOException
+ * @return number of bytes written
+ */
+ public int toFile(final File file) {
+ return toFile(file.toPath());
+ }
+
+ /**
+ * Encodes the image and saves data into {@code filePath}.
+ * @param filePath destination file where the encoded data will be written
+ * @throws NullPointerException if the image has not been set.
+ * @throws UncheckedIOException instead of IOException
+ * @return number of bytes written
+ */
+ public int toFile(final Path filePath) {
+ try (OutputStream outputStream = Files.newOutputStream(filePath)) {
+ return toStream(outputStream);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Encodes the image and saves data into {@code fileName}.
+ * @param fileName destination file where the encoded data will be written
+ * @throws NullPointerException if the image has not been set.
+ * @throws UncheckedIOException instead of IOException
+ * @return number of bytes written
+ */
+ public int toFile(final String fileName) {
+ return toFile(Paths.get(fileName));
+ }
+
+ /**
+ * Encodes the image to outputStream.
+ * @param outputStream destination of the encoded data
+ * @throws NullPointerException if the image has not been set.
+ * @return number of bytes written
+ */
+ public int toStream(final OutputStream outputStream) {
+ try {
+ return PngEncoderLogic.encode(this.bufferedImage, outputStream, this.compressionLevel, this.multiThreadedCompressionEnabled, this.srgbRenderingIntent, this.physicalPixelDimensions);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Returns a new PngEncoder which has the same configuration as this one except {@code bufferedImage}.
+ * The new PngEncoder will use the provided {@code bufferedImage}.
+ *
+ * @param bufferedImage input image
+ * @return a new PngEncoder
+ */
+ public PngEncoder withBufferedImage(final Image bufferedImage) {
+ return new PngEncoder(bufferedImage, this.compressionLevel, this.multiThreadedCompressionEnabled, this.srgbRenderingIntent, this.physicalPixelDimensions);
+ }
+
+ /**
+ * Returns a new PngEncoder which has the same configuration as this one except {@code compressionLevel}.
+ * The new PngEncoder will use the provided {@code compressionLevel}.
+ *
+ * @param compressionLevel input image (must be between -1 and 9 inclusive)
+ * @return a new PngEncoder
+ */
+ public PngEncoder withCompressionLevel(final int compressionLevel) {
+ return new PngEncoder(this.bufferedImage, compressionLevel, this.multiThreadedCompressionEnabled, this.srgbRenderingIntent, this.physicalPixelDimensions);
+ }
+
+ /**
+ * Returns a new PngEncoder which has the same configuration as this one except {@code multiThreadedCompressionEnabled}.
+ * The new PngEncoder will use the provided {@code multiThreadedCompressionEnabled}.
+ *
+ * @param multiThreadedCompressionEnabled when {@code true}, multithreaded compression will be used
+ * @return a new PngEncoder
+ */
+ public PngEncoder withMultiThreadedCompressionEnabled(final boolean multiThreadedCompressionEnabled) {
+ return new PngEncoder(this.bufferedImage, this.compressionLevel, multiThreadedCompressionEnabled, this.srgbRenderingIntent, this.physicalPixelDimensions);
+ }
+
+ public PngEncoder withPhysicalPixelDimensions(final PngEncoderPhysicalPixelDimensions physicalPixelDimensions) {
+ return new PngEncoder(this.bufferedImage, this.compressionLevel, this.multiThreadedCompressionEnabled, this.srgbRenderingIntent, physicalPixelDimensions);
+ }
+
+ /**
+ * Returns a new PngEncoder which has the same configuration as this one except {@code srgbRenderingIntent}.
+ * The new PngEncoder will add an sRGB chunk to the encoded PNG and use the provided {@code srgbRenderingIntent}.
+ *
+ * @param srgbRenderingIntent the rendering intent that should be used when displaying the image
+ * @return a new PngEncoder
+ */
+ public PngEncoder withSrgbRenderingIntent(final PngEncoderSrgbRenderingIntent srgbRenderingIntent) {
+ return new PngEncoder(this.bufferedImage, this.compressionLevel, this.multiThreadedCompressionEnabled, srgbRenderingIntent, this.physicalPixelDimensions);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderCountingOutputStream.java b/src/org/atriasoft/pngencoder/PngEncoderCountingOutputStream.java
new file mode 100644
index 0000000..9d81519
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderCountingOutputStream.java
@@ -0,0 +1,30 @@
+package org.atriasoft.pngencoder;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Objects;
+
+class PngEncoderCountingOutputStream extends FilterOutputStream {
+ private int count;
+
+ PngEncoderCountingOutputStream(final OutputStream out) {
+ super(Objects.requireNonNull(out, "out"));
+ }
+
+ public int getCount() {
+ return this.count;
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws IOException {
+ this.out.write(b, off, len);
+ this.count += len;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ this.out.write(b);
+ this.count++;
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterBuffer.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterBuffer.java
new file mode 100644
index 0000000..ed1b0ac
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterBuffer.java
@@ -0,0 +1,32 @@
+package org.atriasoft.pngencoder;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Objects;
+import java.util.zip.Adler32;
+
+class PngEncoderDeflaterBuffer {
+ final byte[] bytes;
+ int length;
+ final PngEncoderDeflaterBufferPool pool;
+
+ PngEncoderDeflaterBuffer(final PngEncoderDeflaterBufferPool pool, final int maxLength) {
+ this.pool = Objects.requireNonNull(pool, "pool");
+ this.bytes = new byte[maxLength];
+ this.length = 0;
+ }
+
+ long calculateAdler32() {
+ Adler32 adler32 = new Adler32();
+ adler32.update(this.bytes, 0, this.length);
+ return adler32.getValue();
+ }
+
+ void giveBack() {
+ this.pool.giveBack(this);
+ }
+
+ void write(final OutputStream outputStream) throws IOException {
+ outputStream.write(this.bytes, 0, this.length);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterBufferPool.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterBufferPool.java
new file mode 100644
index 0000000..7a2dfdc
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterBufferPool.java
@@ -0,0 +1,35 @@
+package org.atriasoft.pngencoder;
+
+import java.util.LinkedList;
+import java.util.Queue;
+
+class PngEncoderDeflaterBufferPool {
+ private final int bufferMaxLength;
+ protected final Queue buffers;
+
+ PngEncoderDeflaterBufferPool(final int bufferMaxLength) {
+ this.bufferMaxLength = bufferMaxLength;
+ this.buffers = new LinkedList<>();
+ }
+
+ PngEncoderDeflaterBuffer borrow() {
+ PngEncoderDeflaterBuffer buffer = this.buffers.poll();
+ if (buffer == null) {
+ buffer = new PngEncoderDeflaterBuffer(this, this.bufferMaxLength);
+ }
+ return buffer;
+ }
+
+ public int getBufferMaxLength() {
+ return this.bufferMaxLength;
+ }
+
+ void giveBack(final PngEncoderDeflaterBuffer buffer) {
+ buffer.length = 0;
+ this.buffers.offer(buffer);
+ }
+
+ int size() {
+ return this.buffers.size();
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorService.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorService.java
new file mode 100644
index 0000000..4c4d838
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorService.java
@@ -0,0 +1,19 @@
+package org.atriasoft.pngencoder;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+class PngEncoderDeflaterExecutorService {
+ private static class Holder {
+ private static final ExecutorService INSTANCE = Executors.newFixedThreadPool(PngEncoderDeflaterExecutorService.NUM_THREADS_IS_AVAILABLE_PROCESSORS,
+ PngEncoderDeflaterExecutorServiceThreadFactory.getInstance());
+ }
+
+ public static int NUM_THREADS_IS_AVAILABLE_PROCESSORS = Runtime.getRuntime().availableProcessors();
+
+ static ExecutorService getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private PngEncoderDeflaterExecutorService() {}
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactory.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactory.java
new file mode 100644
index 0000000..e99a178
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactory.java
@@ -0,0 +1,31 @@
+package org.atriasoft.pngencoder;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicLong;
+
+class PngEncoderDeflaterExecutorServiceThreadFactory implements ThreadFactory {
+ private static class Holder {
+ private static final PngEncoderDeflaterExecutorServiceThreadFactory INSTANCE = new PngEncoderDeflaterExecutorServiceThreadFactory();
+ }
+
+ static PngEncoderDeflaterExecutorServiceThreadFactory getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private final AtomicLong counter;
+ private final ThreadFactory defaultThreadFactory;
+
+ PngEncoderDeflaterExecutorServiceThreadFactory() {
+ this.defaultThreadFactory = Executors.defaultThreadFactory();
+ this.counter = new AtomicLong(0);
+ }
+
+ @Override
+ public Thread newThread(final Runnable runnable) {
+ Thread thread = this.defaultThreadFactory.newThread(runnable);
+ thread.setName("PngEncoder Deflater (" + this.counter.getAndIncrement() + ")");
+ thread.setDaemon(true);
+ return thread;
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterOutputStream.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterOutputStream.java
new file mode 100644
index 0000000..2a01137
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterOutputStream.java
@@ -0,0 +1,206 @@
+package org.atriasoft.pngencoder;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+// https://tools.ietf.org/html/rfc1950
+// https://stackoverflow.com/questions/9050260/what-does-a-zlib-header-look-like
+// https://www.euccas.me/zlib/
+// https://stackoverflow.com/questions/13132136/java-multithreaded-compression-with-deflater
+class PngEncoderDeflaterOutputStream extends FilterOutputStream {
+ // The maximum amount of queued tasks.
+ // Multiplied because some segments compress faster than others.
+ // A value of 3 seems to keep all threads busy.
+ static final int COUNT_MAX_QUEUED_TASKS = PngEncoderDeflaterExecutorService.NUM_THREADS_IS_AVAILABLE_PROCESSORS * 3;
+
+ // Enforces writing to underlying stream in main thread.
+ // Multiplied so that not all work is finished before flush to underlying stream.
+ static final int COUNT_MAX_TOTAL_SEGMENTS = PngEncoderDeflaterOutputStream.COUNT_MAX_QUEUED_TASKS * 3;
+
+ // The maximum dictionary size according to the deflate specification.
+ // A segment max length lower than this would not allow for future use of dictionary.
+ // Used for unit test sanity checking.
+ static final int SEGMENT_MAX_LENGTH_DICTIONARY = 32 * 1024;
+
+ // Our minimum segment length.
+ // Corresponds to about 2% size overhead.
+ // A lower value would better parallelize images but increase the size overhead.
+ static final int SEGMENT_MAX_LENGTH_ORIGINAL_MIN = 128 * 1024;
+
+ static byte getFlg(final int compressionLevel) {
+ if (compressionLevel == -1 || compressionLevel == 6) {
+ return (byte) 0x9C;
+ }
+
+ if (compressionLevel >= 0 && compressionLevel <= 1) {
+ return (byte) 0x01;
+ }
+
+ if (compressionLevel >= 2 && compressionLevel <= 5) {
+ return (byte) 0x5E;
+ }
+
+ if (compressionLevel >= 7 && compressionLevel <= 9) {
+ return (byte) 0xDA;
+ }
+
+ throw new IllegalArgumentException("Invalid compressionLevel: " + compressionLevel);
+ }
+
+ public static int getSegmentMaxLengthDeflated(final int segmentMaxLengthOriginal) {
+ return segmentMaxLengthOriginal + (segmentMaxLengthOriginal >> 3);
+ }
+
+ public static int getSegmentMaxLengthOriginal(final int totalOriginalBytesLength) {
+ return Math.max(totalOriginalBytesLength / PngEncoderDeflaterOutputStream.COUNT_MAX_TOTAL_SEGMENTS, PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_ORIGINAL_MIN);
+ }
+
+ static void writeDeflateHeader(final OutputStream outputStream, final int compressionLevel) throws IOException {
+ // Write "CMF"
+ // " ... In practice, this means the first byte is almost always 78 (hex) ..."
+ outputStream.write(0x78);
+
+ // Write "FLG"
+ byte flg = PngEncoderDeflaterOutputStream.getFlg(compressionLevel);
+ outputStream.write(flg);
+ }
+
+ private long adler32;
+ private boolean closed;
+ private final int compressionLevel;
+ private boolean finished;
+ private PngEncoderDeflaterBuffer originalSegment;
+ private final PngEncoderDeflaterBufferPool pool;
+ private final ConcurrentLinkedQueue> resultQueue;
+
+ private final int segmentMaxLengthOriginal;
+
+ private final byte[] singleByte;
+
+ PngEncoderDeflaterOutputStream(final OutputStream out, final int compressionLevel, final int segmentMaxLengthOriginal) throws IOException {
+ this(out, compressionLevel, segmentMaxLengthOriginal, new PngEncoderDeflaterBufferPool(PngEncoderDeflaterOutputStream.getSegmentMaxLengthDeflated(segmentMaxLengthOriginal)));
+ }
+
+ PngEncoderDeflaterOutputStream(final OutputStream out, final int compressionLevel, final int segmentMaxLengthOriginal, final PngEncoderDeflaterBufferPool pool) throws IOException {
+ super(Objects.requireNonNull(out, "out"));
+ this.pool = Objects.requireNonNull(pool, "pool");
+ this.singleByte = new byte[1];
+ this.compressionLevel = compressionLevel;
+ this.segmentMaxLengthOriginal = segmentMaxLengthOriginal;
+ this.resultQueue = new ConcurrentLinkedQueue<>();
+ this.originalSegment = pool.borrow();
+ this.adler32 = 1;
+ this.finished = false;
+ this.closed = false;
+ if (pool.getBufferMaxLength() != PngEncoderDeflaterOutputStream.getSegmentMaxLengthDeflated(segmentMaxLengthOriginal)) {
+ throw new IllegalArgumentException("Mismatch between segmentMaxLengthOriginal and pool.");
+ }
+ PngEncoderDeflaterOutputStream.writeDeflateHeader(out, compressionLevel);
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (this.closed) {
+ return;
+ }
+ this.closed = true;
+ finish();
+ super.close();
+ }
+
+ public void finish() throws IOException {
+ if (this.finished) {
+ return;
+ }
+ this.finished = true;
+ try {
+ submitTask(true);
+ joinUntilMaximumQueueSize(0);
+ this.out.write(ByteBuffer.allocate(4).putInt((int) this.adler32).array());
+ this.out.flush();
+ } finally {
+ this.originalSegment.giveBack();
+ }
+ }
+
+ void joinOne() throws IOException {
+ CompletableFuture resultFuture = this.resultQueue.poll();
+ if (resultFuture != null) {
+ final PngEncoderDeflaterSegmentResult result;
+ try {
+ result = resultFuture.join();
+ } catch (RuntimeException e) {
+ throw new IOException("An async segment task failed.", e);
+ }
+ try {
+ this.adler32 = result.getUpdatedAdler32(this.adler32);
+ result.getDeflatedSegment().write(this.out);
+ } finally {
+ result.getOriginalSegment().giveBack();
+ result.getDeflatedSegment().giveBack();
+ }
+ }
+ }
+
+ void joinUntilMaximumQueueSize(final int maximumResultQueueSize) throws IOException {
+ while (this.resultQueue.size() > maximumResultQueueSize) {
+ joinOne();
+ }
+ }
+
+ void submitTask(final boolean lastSegment) {
+ final PngEncoderDeflaterBuffer deflatedSegment = this.pool.borrow();
+ final PngEncoderDeflaterSegmentTask task = new PngEncoderDeflaterSegmentTask(this.originalSegment, deflatedSegment, this.compressionLevel, lastSegment);
+ submitTask(task);
+ this.originalSegment = this.pool.borrow();
+ }
+
+ void submitTask(final PngEncoderDeflaterSegmentTask task) {
+ CompletableFuture future = CompletableFuture.supplyAsync(task, PngEncoderDeflaterExecutorService.getInstance());
+ this.resultQueue.offer(future);
+ }
+
+ @Override
+ public void write(final byte[] b) throws IOException {
+ write(b, 0, b.length);
+ }
+
+ @Override
+ public void write(final byte[] b, int off, int len) throws IOException {
+ if (this.finished) {
+ throw new IOException("write beyond end of stream");
+ }
+ if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len == 0) {
+ return;
+ }
+
+ while (len > 0) {
+ int freeBufCount = this.segmentMaxLengthOriginal - this.originalSegment.length;
+ if (freeBufCount == 0) {
+ // Submit task if the buffer is full and there still is more to write.
+ joinUntilMaximumQueueSize(PngEncoderDeflaterOutputStream.COUNT_MAX_QUEUED_TASKS - 1);
+ submitTask(false);
+ } else {
+ int toCopyCount = Math.min(len, freeBufCount);
+ System.arraycopy(b, off, this.originalSegment.bytes, this.originalSegment.length, toCopyCount);
+ this.originalSegment.length += toCopyCount;
+ off += toCopyCount;
+ len -= toCopyCount;
+ }
+ }
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ this.singleByte[0] = (byte) (b & 0xff);
+ write(this.singleByte, 0, 1);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentResult.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentResult.java
new file mode 100644
index 0000000..cc40a94
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentResult.java
@@ -0,0 +1,59 @@
+package org.atriasoft.pngencoder;
+
+import java.util.Objects;
+
+class PngEncoderDeflaterSegmentResult {
+ // https://github.com/madler/zlib/blob/master/adler32.c#L143
+ static long combine(final long adler1, final long adler2, final long len2) {
+ long BASEL = 65521;
+ long sum1;
+ long sum2;
+ long rem;
+
+ rem = len2 % BASEL;
+ sum1 = adler1 & 0xffffL;
+ sum2 = rem * sum1;
+ sum2 %= BASEL;
+ sum1 += (adler2 & 0xffffL) + BASEL - 1;
+ sum2 += ((adler1 >> 16) & 0xffffL) + ((adler2 >> 16) & 0xffffL) + BASEL - rem;
+ if (sum1 >= BASEL) {
+ sum1 -= BASEL;
+ }
+ if (sum1 >= BASEL) {
+ sum1 -= BASEL;
+ }
+ if (sum2 >= (BASEL << 1)) {
+ sum2 -= (BASEL << 1);
+ }
+ if (sum2 >= BASEL) {
+ sum2 -= BASEL;
+ }
+ return sum1 | (sum2 << 16);
+ }
+
+ private final PngEncoderDeflaterBuffer deflatedSegment;
+ private final PngEncoderDeflaterBuffer originalSegment;
+ private final long originalSegmentAdler32;
+
+ private final int originalSegmentLength;
+
+ PngEncoderDeflaterSegmentResult(final PngEncoderDeflaterBuffer originalSegment, final PngEncoderDeflaterBuffer deflatedSegment, final long originalSegmentAdler32,
+ final int originalSegmentLength) {
+ this.originalSegment = Objects.requireNonNull(originalSegment, "originalSegment");
+ this.deflatedSegment = Objects.requireNonNull(deflatedSegment, "deflatedSegment");
+ this.originalSegmentAdler32 = originalSegmentAdler32;
+ this.originalSegmentLength = originalSegmentLength;
+ }
+
+ public PngEncoderDeflaterBuffer getDeflatedSegment() {
+ return this.deflatedSegment;
+ }
+
+ public PngEncoderDeflaterBuffer getOriginalSegment() {
+ return this.originalSegment;
+ }
+
+ long getUpdatedAdler32(final long originalAdler32) {
+ return PngEncoderDeflaterSegmentResult.combine(originalAdler32, this.originalSegmentAdler32, this.originalSegmentLength);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentTask.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentTask.java
new file mode 100644
index 0000000..04cf6db
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentTask.java
@@ -0,0 +1,41 @@
+package org.atriasoft.pngencoder;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.zip.Deflater;
+
+class PngEncoderDeflaterSegmentTask implements Supplier {
+ static void deflate(final PngEncoderDeflaterBuffer originalSegment, final PngEncoderDeflaterBuffer deflatedSegment, final int compressionLevel, final boolean lastSegment) {
+ final Deflater deflater = PngEncoderDeflaterThreadLocalDeflater.getInstance(compressionLevel);
+ deflater.setInput(originalSegment.bytes, 0, originalSegment.length);
+
+ if (lastSegment) {
+ deflater.finish();
+ }
+
+ deflatedSegment.length = deflater.deflate(deflatedSegment.bytes, 0, deflatedSegment.bytes.length, lastSegment ? Deflater.NO_FLUSH : Deflater.SYNC_FLUSH);
+ }
+
+ private final int compressionLevel;
+ private final PngEncoderDeflaterBuffer deflatedSegment;
+ private final boolean lastSegment;
+
+ private final PngEncoderDeflaterBuffer originalSegment;
+
+ public PngEncoderDeflaterSegmentTask(final PngEncoderDeflaterBuffer originalSegment, final PngEncoderDeflaterBuffer deflatedSegment, final int compressionLevel, final boolean lastSegment) {
+ this.originalSegment = Objects.requireNonNull(originalSegment, "originalSegment");
+ this.deflatedSegment = Objects.requireNonNull(deflatedSegment, "deflatedSegment");
+ this.compressionLevel = compressionLevel;
+ this.lastSegment = lastSegment;
+ }
+
+ @Override
+ public PngEncoderDeflaterSegmentResult get() {
+ final long originalSegmentAdler32 = this.originalSegment.calculateAdler32();
+ final int originalSegmentLength = this.originalSegment.length;
+
+ PngEncoderDeflaterSegmentTask.deflate(this.originalSegment, this.deflatedSegment, this.compressionLevel, this.lastSegment);
+
+ return new PngEncoderDeflaterSegmentResult(this.originalSegment, this.deflatedSegment, originalSegmentAdler32, originalSegmentLength);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflater.java b/src/org/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflater.java
new file mode 100644
index 0000000..f795bdc
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflater.java
@@ -0,0 +1,33 @@
+package org.atriasoft.pngencoder;
+
+import java.util.zip.Deflater;
+
+/**
+ * We save time by allocating and reusing some thread local state.
+ *
+ * Creating a new Deflater instance takes a surprising amount of time.
+ * Resetting an existing Deflater instance is almost free though.
+ */
+class PngEncoderDeflaterThreadLocalDeflater {
+ private static final ThreadLocal THREAD_LOCAL = ThreadLocal.withInitial(PngEncoderDeflaterThreadLocalDeflater::new);
+
+ static Deflater getInstance(final int compressionLevel) {
+ return PngEncoderDeflaterThreadLocalDeflater.THREAD_LOCAL.get().getDeflater(compressionLevel);
+ }
+
+ private final Deflater[] deflaters;
+
+ private PngEncoderDeflaterThreadLocalDeflater() {
+ this.deflaters = new Deflater[11];
+ for (int compressionLevel = -1; compressionLevel <= 9; compressionLevel++) {
+ boolean nowrap = true;
+ this.deflaters[compressionLevel + 1] = new Deflater(compressionLevel, nowrap);
+ }
+ }
+
+ private Deflater getDeflater(final int compressionLevel) {
+ Deflater deflater = this.deflaters[compressionLevel + 1];
+ deflater.reset();
+ return deflater;
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderIdatChunksOutputStream.java b/src/org/atriasoft/pngencoder/PngEncoderIdatChunksOutputStream.java
new file mode 100644
index 0000000..817bb4b
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderIdatChunksOutputStream.java
@@ -0,0 +1,87 @@
+package org.atriasoft.pngencoder;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.CRC32;
+
+class PngEncoderIdatChunksOutputStream extends FilterOutputStream {
+ // An IDAT chunk adds 12 bytes of overhead to the data within.
+ // 12 / (32 * 1024) = 0.00037 meaning the size overhead is just 0.037% which should be negligible.
+ static final int DEFAULT_BUFFER_LENGTH = 32 * 1024;
+
+ static final byte[] IDAT_BYTES = "IDAT".getBytes(StandardCharsets.US_ASCII);
+
+ private final byte[] buf;
+ private int count;
+ private final CRC32 crc;
+
+ PngEncoderIdatChunksOutputStream(final OutputStream out) {
+ this(out, PngEncoderIdatChunksOutputStream.DEFAULT_BUFFER_LENGTH);
+ }
+
+ PngEncoderIdatChunksOutputStream(final OutputStream out, final int bufferLength) {
+ super(out);
+ this.crc = new CRC32();
+ this.buf = new byte[bufferLength];
+ this.count = 0;
+ }
+
+ @Override
+ public void flush() throws IOException {
+ flushBuffer();
+ super.flush();
+ }
+
+ private void flushBuffer() throws IOException {
+ if (this.count > 0) {
+ writeIdatChunk(this.buf, 0, this.count);
+ this.count = 0;
+ }
+ }
+
+ @Override
+ public void write(final byte[] b) throws IOException {
+ write(b, 0, b.length);
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws IOException {
+ if (len >= this.buf.length) {
+ flushBuffer();
+ writeIdatChunk(b, off, len);
+ return;
+ }
+ if (len > this.buf.length - this.count) {
+ flushBuffer();
+ }
+ System.arraycopy(b, off, this.buf, this.count, len);
+ this.count += len;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ if (this.count >= this.buf.length) {
+ flushBuffer();
+ }
+ this.buf[this.count++] = (byte) b;
+ }
+
+ private void writeIdatChunk(final byte[] b, final int off, final int len) throws IOException {
+ writeInt(len);
+ this.out.write(PngEncoderIdatChunksOutputStream.IDAT_BYTES);
+ this.out.write(b, off, len);
+ this.crc.reset();
+ this.crc.update(PngEncoderIdatChunksOutputStream.IDAT_BYTES);
+ this.crc.update(b, off, len);
+ writeInt((int) this.crc.getValue());
+ }
+
+ private void writeInt(final int i) throws IOException {
+ this.out.write((byte) (i >> 24) & 0xFF);
+ this.out.write((byte) (i >> 16) & 0xFF);
+ this.out.write((byte) (i >> 8) & 0xFF);
+ this.out.write((byte) i & 0xFF);
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderLogic.java b/src/org/atriasoft/pngencoder/PngEncoderLogic.java
new file mode 100644
index 0000000..b077a42
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderLogic.java
@@ -0,0 +1,138 @@
+package org.atriasoft.pngencoder;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.zip.CRC32;
+import java.util.zip.Deflater;
+import java.util.zip.DeflaterOutputStream;
+
+import org.atriasoft.egami.ToolImage;
+import org.atriasoft.egami.Image;
+
+class PngEncoderLogic {
+ public static final byte[] CHRM_SRGB_VALUE = ByteBuffer.allocate(8 * 4).putInt(31270).putInt(32900).putInt(64000).putInt(33000).putInt(30000).putInt(60000).putInt(15000).putInt(6000).array();
+
+ // In hex: 89 50 4E 47 0D 0A 1A 0A
+ // This is the "file beginning" aka "header" aka "signature" aka "magicnumber".
+ // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
+ // http://www.libpng.org/pub/png/book/chapter08.html#png.ch08.div.2
+ // All PNGs start this way and it does not include any pixel format info.
+ static final byte[] FILE_BEGINNING = { -119, 80, 78, 71, 13, 10, 26, 10 };
+
+ // In hex: 00 00 00 00 49 45 4E 44 AE 42 60 82
+ // This is the "file ending"
+ static final byte[] FILE_ENDING = { 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126 };
+ // Default values for the gAMA and cHRM chunks when an sRGB chunk is used,
+ // as specified at http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.sRGB
+ // "An application that writes the sRGB chunk should also write a gAMA chunk (and perhaps a cHRM chunk)
+ // for compatibility with applications that do not use the sRGB chunk.
+ // In this situation, only the following values may be used:
+ // ..."
+ public static final byte[] GAMA_SRGB_VALUE = ByteBuffer.allocate(4).putInt(45455).array();
+ static final byte IHDR_BIT_DEPTH = 8;
+ static final byte IHDR_COLOR_TYPE_RGB = 2;
+ static final byte IHDR_COLOR_TYPE_RGBA = 6;
+ static final byte IHDR_COMPRESSION_METHOD = 0;
+
+ static final byte IHDR_FILTER_METHOD = 0;
+ static final byte IHDR_INTERLACE_METHOD = 0;
+
+ static byte[] asChunk(final String type, final byte[] data) {
+ PngEncoderVerificationUtil.verifyChunkType(type);
+ ByteBuffer byteBuffer = ByteBuffer.allocate(data.length + 12);
+ byteBuffer.putInt(data.length);
+ ByteBuffer byteBufferForCrc = byteBuffer.slice().asReadOnlyBuffer();
+ byteBufferForCrc.limit(4 + data.length);
+ byteBuffer.put(type.getBytes(StandardCharsets.US_ASCII));
+ byteBuffer.put(data);
+ byteBuffer.putInt(PngEncoderLogic.getCrc32(byteBufferForCrc));
+ return byteBuffer.array();
+ }
+
+ static int encode(final Image bufferedImage, final OutputStream outputStream, final int compressionLevel, final boolean multiThreadedCompressionEnabled,
+ final PngEncoderSrgbRenderingIntent srgbRenderingIntent, final PngEncoderPhysicalPixelDimensions physicalPixelDimensions) throws IOException {
+ Objects.requireNonNull(bufferedImage, "bufferedImage");
+ Objects.requireNonNull(outputStream, "outputStream");
+
+ final boolean alpha = bufferedImage.hasAlpha();
+ final int width = bufferedImage.getWidth();
+ final int height = bufferedImage.getHeight();
+ final PngEncoderCountingOutputStream countingOutputStream = new PngEncoderCountingOutputStream(outputStream);
+
+ countingOutputStream.write(PngEncoderLogic.FILE_BEGINNING);
+
+ final byte[] ihdr = PngEncoderLogic.getIhdrHeader(width, height, alpha);
+ final byte[] ihdrChunk = PngEncoderLogic.asChunk("IHDR", ihdr);
+ countingOutputStream.write(ihdrChunk);
+
+ if (srgbRenderingIntent != null) {
+ outputStream.write(PngEncoderLogic.asChunk("sRGB", new byte[] { srgbRenderingIntent.getValue() }));
+ outputStream.write(PngEncoderLogic.asChunk("gAMA", PngEncoderLogic.GAMA_SRGB_VALUE));
+ outputStream.write(PngEncoderLogic.asChunk("cHRM", PngEncoderLogic.CHRM_SRGB_VALUE));
+ }
+
+ if (physicalPixelDimensions != null) {
+ outputStream.write(PngEncoderLogic.asChunk("pHYs", PngEncoderLogic.getPhysicalPixelDimensions(physicalPixelDimensions)));
+ }
+
+ PngEncoderIdatChunksOutputStream idatChunksOutputStream = new PngEncoderIdatChunksOutputStream(countingOutputStream);
+ final byte[] scanlineBytes;
+
+ if (bufferedImage.hasAlpha()) {
+ scanlineBytes = ToolImage.convertInByteBufferRGBA(bufferedImage);
+ } else {
+ scanlineBytes = ToolImage.convertInByteBufferRGB(bufferedImage);
+ }
+
+ final int segmentMaxLengthOriginal = PngEncoderDeflaterOutputStream.getSegmentMaxLengthOriginal(scanlineBytes.length);
+
+ if (scanlineBytes.length <= segmentMaxLengthOriginal || !multiThreadedCompressionEnabled) {
+ Deflater deflater = new Deflater(compressionLevel);
+ DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(idatChunksOutputStream, deflater);
+ deflaterOutputStream.write(scanlineBytes);
+ deflaterOutputStream.finish();
+ deflaterOutputStream.flush();
+ } else {
+ PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(idatChunksOutputStream, compressionLevel, segmentMaxLengthOriginal);
+ deflaterOutputStream.write(scanlineBytes);
+ deflaterOutputStream.finish();
+ }
+
+ countingOutputStream.write(PngEncoderLogic.FILE_ENDING);
+
+ countingOutputStream.flush();
+
+ return countingOutputStream.getCount();
+ }
+
+ static int getCrc32(final ByteBuffer byteBuffer) {
+ CRC32 crc = new CRC32();
+ crc.update(byteBuffer);
+ return (int) crc.getValue();
+ }
+
+ static byte[] getIhdrHeader(final int width, final int height, final boolean alpha) {
+ ByteBuffer buffer = ByteBuffer.allocate(13);
+ buffer.putInt(width);
+ buffer.putInt(height);
+ buffer.put(PngEncoderLogic.IHDR_BIT_DEPTH);
+ buffer.put(alpha ? PngEncoderLogic.IHDR_COLOR_TYPE_RGBA : PngEncoderLogic.IHDR_COLOR_TYPE_RGB);
+ buffer.put(PngEncoderLogic.IHDR_COMPRESSION_METHOD);
+ buffer.put(PngEncoderLogic.IHDR_FILTER_METHOD);
+ buffer.put(PngEncoderLogic.IHDR_INTERLACE_METHOD);
+ return buffer.array();
+ }
+
+ static byte[] getPhysicalPixelDimensions(final PngEncoderPhysicalPixelDimensions physicalPixelDimensions) {
+ ByteBuffer buffer = ByteBuffer.allocate(9);
+ buffer.putInt(physicalPixelDimensions.getPixelsPerUnitX());
+ buffer.putInt(physicalPixelDimensions.getPixelsPerUnitY());
+ buffer.put(physicalPixelDimensions.getUnit().getValue());
+ return buffer.array();
+ }
+
+ private PngEncoderLogic() {}
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensions.java b/src/org/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensions.java
new file mode 100644
index 0000000..97f4157
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensions.java
@@ -0,0 +1,123 @@
+package org.atriasoft.pngencoder;
+
+/**
+ * Represents PNG physical pixel dimensions
+ *
+ * Use one of the static methods to create physical pixel dimensions based
+ * on pixels per meter, dots per inch or a unit-less aspect ratio.
+ *
+ * @see https://www.w3.org/TR/PNG/#11pHYs
+ */
+public class PngEncoderPhysicalPixelDimensions {
+
+ public enum Unit {
+ METER((byte) 1), UNKNOWN((byte) 0);
+
+ private final byte value;
+
+ Unit(final byte value) {
+ this.value = value;
+ }
+
+ public byte getValue() {
+ return this.value;
+ }
+ }
+
+ private static final float INCHES_PER_METER = 100 / 2.54f;
+
+ /**
+ * Creates a PngEncoderPhysicalPixelDimensions that only specifies the aspect ratio,
+ * but not the size, of the pixels
+ *
+ * @param pixelsPerUnitX the number of pixels per unit in the horizontal dimension
+ * @param pixelsPerUnitY the number of pixels per unit in the vertical dimension
+ */
+ public static PngEncoderPhysicalPixelDimensions aspectRatio(final int pixelsPerUnitX, final int pixelsPerUnitY) {
+ return new PngEncoderPhysicalPixelDimensions(pixelsPerUnitX, pixelsPerUnitY, Unit.UNKNOWN);
+ }
+
+ /**
+ * Creates a PngEncoderPhysicalPixelDimensions with square pixels
+ * with a size specified in dots per inch
+ *
+ * Note that dots per inch (DPI) cannot be exactly represented by the PNG format's
+ * integer value for pixels per meter. There will be a slight rounding error.
+ *
+ * @param dotsPerInch the DPI value for both dimensions
+ */
+ public static PngEncoderPhysicalPixelDimensions dotsPerInch(final int dotsPerInch) {
+ return PngEncoderPhysicalPixelDimensions.dotsPerInch(dotsPerInch, dotsPerInch);
+ }
+
+ /**
+ * Creates a PngEncoderPhysicalPixelDimensions with possibly non-square pixels
+ * with a size specified in dots per inch
+ *
+ * Note that dots per inch (DPI) cannot be exactly represented by the PNG format's
+ * integer value for pixels per meter. There will be a slight rounding error.
+ *
+ * @param dotsPerInchX the DPI value for the horizontal dimension
+ * @param dotsPerInchY the DPI value for the vertical dimension
+ */
+ public static PngEncoderPhysicalPixelDimensions dotsPerInch(final int dotsPerInchX, final int dotsPerInchY) {
+ int pixelsPerMeterX = Math.round(dotsPerInchX * PngEncoderPhysicalPixelDimensions.INCHES_PER_METER);
+ int pixelsPerMeterY = Math.round(dotsPerInchY * PngEncoderPhysicalPixelDimensions.INCHES_PER_METER);
+
+ return new PngEncoderPhysicalPixelDimensions(pixelsPerMeterX, pixelsPerMeterY, Unit.METER);
+ }
+
+ /**
+ * Creates a PngEncoderPhysicalPixelDimensions with square pixels
+ * with a size specified in pixels per meter
+ *
+ * @param pixelsPerMeter the pixels per meter value for both dimensions
+ */
+ public static PngEncoderPhysicalPixelDimensions pixelsPerMeter(final int pixelsPerMeter) {
+ return PngEncoderPhysicalPixelDimensions.pixelsPerMeter(pixelsPerMeter, pixelsPerMeter);
+ }
+
+ /**
+ * Creates a PngEncoderPhysicalPixelDimensions with possibly non-square pixels
+ * with a size specified in pixels per meter
+ *
+ * @param pixelsPerMeterX the pixels per meter value for the horizontal dimension
+ * @param pixelsPerMeterY the pixels per meter value for the vertical dimension
+ */
+ public static PngEncoderPhysicalPixelDimensions pixelsPerMeter(final int pixelsPerMeterX, final int pixelsPerMeterY) {
+ return new PngEncoderPhysicalPixelDimensions(pixelsPerMeterX, pixelsPerMeterY, Unit.METER);
+ }
+
+ private final int pixelsPerUnitX;
+
+ private final int pixelsPerUnitY;
+
+ private final Unit unit;
+
+ private PngEncoderPhysicalPixelDimensions(final int pixelsPerUnitX, final int pixelsPerUnitY, final Unit unit) {
+ this.pixelsPerUnitX = pixelsPerUnitX;
+ this.pixelsPerUnitY = pixelsPerUnitY;
+ this.unit = unit;
+ }
+
+ /**
+ * @return the number of pixels per unit in the horizontal dimension
+ */
+ public int getPixelsPerUnitX() {
+ return this.pixelsPerUnitX;
+ }
+
+ /**
+ * @return the number of pixels per unit in the vertical dimension
+ */
+ public int getPixelsPerUnitY() {
+ return this.pixelsPerUnitY;
+ }
+
+ /**
+ * @return the unit of the pixel size (either {@link Unit#METER} or {@link Unit#UNKNOWN})
+ */
+ public Unit getUnit() {
+ return this.unit;
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderSrgbRenderingIntent.java b/src/org/atriasoft/pngencoder/PngEncoderSrgbRenderingIntent.java
new file mode 100644
index 0000000..1340972
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderSrgbRenderingIntent.java
@@ -0,0 +1,15 @@
+package org.atriasoft.pngencoder;
+
+public enum PngEncoderSrgbRenderingIntent {
+ ABSOLUTE_COLORIMETRIC((byte) 3), PERCEPTUAL((byte) 0), RELATIVE_COLORIMETRIC((byte) 1), SATURATION((byte) 2);
+
+ private final byte value;
+
+ PngEncoderSrgbRenderingIntent(final byte value) {
+ this.value = value;
+ }
+
+ public byte getValue() {
+ return this.value;
+ }
+}
diff --git a/src/org/atriasoft/pngencoder/PngEncoderVerificationUtil.java b/src/org/atriasoft/pngencoder/PngEncoderVerificationUtil.java
new file mode 100644
index 0000000..4981ec6
--- /dev/null
+++ b/src/org/atriasoft/pngencoder/PngEncoderVerificationUtil.java
@@ -0,0 +1,21 @@
+package org.atriasoft.pngencoder;
+
+class PngEncoderVerificationUtil {
+ static String verifyChunkType(final String chunkType) {
+ if (chunkType.length() != 4) {
+ String message = String.format("The chunkType must be four letters, but was \"%s\". See http://www.libpng.org/pub/png/book/chapter08.html#png.ch08.div.1", chunkType);
+ throw new IllegalArgumentException(message);
+ }
+ return chunkType;
+ }
+
+ static int verifyCompressionLevel(final int compressionLevel) {
+ if ((compressionLevel < -1) || (compressionLevel > 9)) {
+ String message = String.format("The compressionLevel must be between -1 and 9 inclusive, but was %d.", compressionLevel);
+ throw new IllegalArgumentException(message);
+ }
+ return compressionLevel;
+ }
+
+ private PngEncoderVerificationUtil() {}
+}
diff --git a/test/PngEncoderBufferedImageConverter.java b/test/PngEncoderBufferedImageConverter.java
new file mode 100644
index 0000000..62227d7
--- /dev/null
+++ b/test/PngEncoderBufferedImageConverter.java
@@ -0,0 +1,159 @@
+package org.atriasoft.pngencoder;
+
+import org.atriasoft.egami.Image;
+
+public class PngEncoderImageInterfaceConverter {
+ private static final int[] BAND_MASKS_INT_ARGB = { 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000 };
+ private static final int[] BAND_MASKS_INT_ARGB_PRE = { 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000 };
+
+ private static final int[] BAND_MASKS_INT_BGR = { 0x000000ff, 0x0000ff00, 0x00ff0000 };
+ private static final int[] BAND_MASKS_INT_RGB = { 0x00ff0000, 0x0000ff00, 0x000000ff };
+
+ private static final int[] BAND_MASKS_USHORT_555_RGB = { 0x7C00, 0x03E0, 0x001F };
+ private static final int[] BAND_MASKS_USHORT_565_RGB = { 0xf800, 0x07E0, 0x001F };
+
+ private static final ColorModel COLOR_MODEL_INT_ARGB = ColorModel.getRGBdefault();
+ private static final ColorModel COLOR_MODEL_INT_ARGB_PRE = new DirectColorModel(ColorSpace.getInstance(ColorSpace.CS_sRGB), 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000, true,
+ DataBuffer.TYPE_INT);
+
+ private static final ColorModel COLOR_MODEL_INT_BGR = new DirectColorModel(24, 0x000000ff, 0x0000ff00, 0x00ff0000);
+
+ private static final ColorModel COLOR_MODEL_INT_RGB = new DirectColorModel(24, 0x00ff0000, 0x0000ff00, 0x000000ff, 0x0);
+
+ public static Image copyType(final Image ImageInterface, final PngEncoderImageInterfaceType type) {
+ final int width = ImageInterface.getWidth();
+ final int height = ImageInterface.getHeight();
+ final Image convertedImageInterface = new Image(width, height, type.ordinal());
+ final Graphics graphics = convertedImageInterface.getGraphics();
+ if (!convertedImageInterface.hasAlpha()) {
+ graphics.setColor(Color.WHITE);
+ graphics.fillRect(0, 0, width, height);
+ }
+ graphics.drawImage(ImageInterface, 0, 0, null);
+ graphics.dispose();
+ return convertedImageInterface;
+ }
+
+ public static Image createFrom3ByteBgr(final byte[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferByte(data, data.length);
+ ColorSpace colorSpace = ColorSpace.getInstance(ColorSpace.CS_sRGB);
+ int[] nBits = { 8, 8, 8 };
+ int[] bOffs = { 2, 1, 0 };
+ ColorModel colorModel = new ComponentColorModel(colorSpace, nBits, false, false, Transparency.OPAQUE, DataBuffer.TYPE_BYTE);
+ WritableRaster raster = Raster.createInterleavedRaster(dataBuffer, width, height, width * 3, 3, bOffs, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image createFrom4ByteAbgr(final byte[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferByte(data, data.length);
+ ColorSpace colorSpace = ColorSpace.getInstance(ColorSpace.CS_sRGB);
+ int[] nBits = { 8, 8, 8, 8 };
+ int[] bOffs = { 3, 2, 1, 0 };
+ ColorModel colorModel = new ComponentColorModel(colorSpace, nBits, true, false, Transparency.TRANSLUCENT, DataBuffer.TYPE_BYTE);
+ WritableRaster raster = Raster.createInterleavedRaster(dataBuffer, width, height, width * 4, 4, bOffs, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image createFrom4ByteAbgrPre(final byte[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferByte(data, data.length);
+ ColorSpace colorSpace = ColorSpace.getInstance(ColorSpace.CS_sRGB);
+ int[] nBits = { 8, 8, 8, 8 };
+ int[] bOffs = { 3, 2, 1, 0 };
+ ColorModel colorModel = new ComponentColorModel(colorSpace, nBits, true, true, Transparency.TRANSLUCENT, DataBuffer.TYPE_BYTE);
+ WritableRaster raster = Raster.createInterleavedRaster(dataBuffer, width, height, width * 4, 4, bOffs, null);
+ return new Image(colorModel, raster, true, null);
+ }
+
+ public static Image createFromByteBinary(final byte[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferByte(data, data.length);
+ byte[] arr = { (byte) 0, (byte) 0xff };
+ IndexColorModel colorModel = new IndexColorModel(1, 2, arr, arr, arr);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, 1, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image createFromByteGray(final byte[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferByte(data, data.length);
+ ColorSpace colorSpace = ColorSpace.getInstance(ColorSpace.CS_GRAY);
+ int[] nBits = { 8 };
+ ColorModel colorModel = new ComponentColorModel(colorSpace, nBits, false, true, Transparency.OPAQUE, DataBuffer.TYPE_BYTE);
+ int[] bandOffsets = { 0 };
+ WritableRaster raster = Raster.createInterleavedRaster(dataBuffer, width, height, width, 1, bandOffsets, null);
+ return new Image(colorModel, raster, true, null);
+ }
+
+ public static Image createFromIntArgb(final int[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferInt(data, data.length);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_INT_ARGB, null);
+ return new Image(PngEncoderImageInterfaceConverter.COLOR_MODEL_INT_ARGB, raster, false, null);
+ }
+
+ public static Image createFromIntArgbPre(final int[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferInt(data, data.length);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_INT_ARGB_PRE, null);
+ return new Image(PngEncoderImageInterfaceConverter.COLOR_MODEL_INT_ARGB_PRE, raster, true, null);
+ }
+
+ public static Image createFromIntBgr(final int[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferInt(data, data.length);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_INT_BGR, null);
+ return new Image(PngEncoderImageInterfaceConverter.COLOR_MODEL_INT_BGR, raster, false, null);
+ }
+
+ public static Image createFromIntRgb(final int[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferInt(data, data.length);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_INT_RGB, null);
+ return new Image(PngEncoderImageInterfaceConverter.COLOR_MODEL_INT_RGB, raster, false, null);
+ }
+
+ public static Image createFromUshort555Rgb(final short[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferUShort(data, data.length);
+ ColorModel colorModel = new DirectColorModel(15, PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_555_RGB[0], PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_555_RGB[1],
+ PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_555_RGB[2]);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_555_RGB, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image createFromUshort565Rgb(final short[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferUShort(data, data.length);
+ ColorModel colorModel = new DirectColorModel(16, PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_565_RGB[0], PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_565_RGB[1],
+ PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_565_RGB[2]);
+ WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, PngEncoderImageInterfaceConverter.BAND_MASKS_USHORT_565_RGB, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image createFromUshortGray(final short[] data, final int width, final int height) {
+ DataBuffer dataBuffer = new DataBufferUShort(data, data.length);
+ ColorSpace colorSpace = ColorSpace.getInstance(ColorSpace.CS_GRAY);
+ int[] nBits = { 16 };
+ ColorModel colorModel = new ComponentColorModel(colorSpace, nBits, false, false, Transparency.OPAQUE, DataBuffer.TYPE_USHORT);
+ int[] bandOffsets = { 0 };
+ WritableRaster raster = Raster.createInterleavedRaster(dataBuffer, width, height, width, 1, bandOffsets, null);
+ return new Image(colorModel, raster, false, null);
+ }
+
+ public static Image ensureType(final Image ImageInterface, final PngEncoderImageInterfaceType type) {
+ if (PngEncoderImageInterfaceType.valueOf(ImageInterface) == type) {
+ return ImageInterface;
+ }
+ return PngEncoderImageInterfaceConverter.copyType(ImageInterface, type);
+ }
+
+ public static DataBuffer getDataBuffer(final Image ImageInterface) {
+ return ImageInterface.getRaster().getDataBuffer();
+ }
+
+ public static DataBufferByte getDataBufferByte(final Image ImageInterface) {
+ return (DataBufferByte) PngEncoderImageInterfaceConverter.getDataBuffer(ImageInterface);
+ }
+
+ public static DataBufferInt getDataBufferInt(final Image ImageInterface) {
+ return (DataBufferInt) PngEncoderImageInterfaceConverter.getDataBuffer(ImageInterface);
+ }
+
+ public static DataBufferUShort getDataBufferUShort(final Image ImageInterface) {
+ return (DataBufferUShort) PngEncoderImageInterfaceConverter.getDataBuffer(ImageInterface);
+ }
+
+ private PngEncoderImageInterfaceConverter() {}
+}
diff --git a/test/resources/looklet-look-scale6.png b/test/resources/looklet-look-scale6.png
new file mode 100644
index 0000000..513b573
Binary files /dev/null and b/test/resources/looklet-look-scale6.png differ
diff --git a/test/resources/png-encoder-logo.png b/test/resources/png-encoder-logo.png
new file mode 100644
index 0000000..862ae70
Binary files /dev/null and b/test/resources/png-encoder-logo.png differ
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkAssorted.java b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkAssorted.java
new file mode 100644
index 0000000..99e949a
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkAssorted.java
@@ -0,0 +1,76 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import javax.imageio.ImageIO;
+
+public class PngEncoderBenchmarkAssorted {
+ @Disabled("run manually")
+ @Test
+ public void runBenchmarkCustom() throws IOException {
+ Timing.message("started");
+
+ //final Image original = readTestImageResource("png-encoder-logo.png");
+ final Image original = PngEncoderImageConverter.ensureType(PngEncoderTestUtil.readTestImageResource("looklet-look-scale6.png"), PngEncoderImageType.TYPE_INT_RGB);
+ Timing.message("loaded");
+
+ final File outImageIO = File.createTempFile("out-imageio", ".png");
+ //final File outPngEncoder = File.createTempFile("out-pngencoder", ".png");
+ final File outPngEncoder = new File("/Users/olof/Desktop/out.png");
+
+ ImageIO.write(original, "png", outImageIO);
+ Timing.message("ImageIO Warmup");
+
+ ImageIO.write(original, "png", outImageIO);
+ Timing.message("ImageIO Result");
+
+ PngEncoder pngEncoder = new PngEncoder()
+ //.withMultiThreadedCompressionEnabled(false)
+ .withCompressionLevel(9).withImage(original);
+ System.out.println(outPngEncoder);
+
+ pngEncoder.toFile(outPngEncoder);
+ Timing.message("PngEncoder Warmup");
+
+ pngEncoder.toFile(outPngEncoder);
+ Timing.message("PngEncoder Result");
+
+ final long imageIOSize = outImageIO.length();
+ final long pngEncoderSize = outPngEncoder.length();
+
+ if (imageIOSize != 0) {
+ System.out.println("imageIOSize: " + imageIOSize);
+ }
+
+ if (pngEncoderSize != 0) {
+ System.out.println("pngEncoderSize: " + pngEncoderSize);
+ }
+
+ if (imageIOSize != 0 && pngEncoderSize != 0) {
+ System.out.println("pngEncoderSize / imageIOSize: " + (double) pngEncoderSize / (double) imageIOSize);
+ }
+ }
+
+ @Disabled("run manually")
+ @Test
+ public void runBenchmarkIntelliJIdeaProfilerImageIO() {
+ final int times = 1;
+ final Image Image = PngEncoderImageConverter.ensureType(PngEncoderTestUtil.readTestImageResource("looklet-look-scale6.png"), PngEncoderImageType.TYPE_INT_ARGB);
+ for (int i = 0; i < times; i++) {
+ PngEncoderTestUtil.encodeWithImageIO(Image);
+ }
+ }
+
+ @Disabled("run manually")
+ @Test
+ public void runBenchmarkIntelliJIdeaProfilerPngEncoder() {
+ final int times = 10;
+ final Image Image = PngEncoderImageConverter.ensureType(PngEncoderTestUtil.readTestImageResource("looklet-look-scale6.png"), PngEncoderImageType.TYPE_INT_ARGB);
+ for (int i = 0; i < times; i++) {
+ PngEncoderTestUtil.encodeWithPngEncoder(Image);
+ }
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkCompressionSpeedVsSize.java b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkCompressionSpeedVsSize.java
new file mode 100644
index 0000000..2226d96
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkCompressionSpeedVsSize.java
@@ -0,0 +1,86 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import java.util.concurrent.TimeUnit;
+
+public class PngEncoderBenchmarkCompressionSpeedVsSize {
+ @State(Scope.Benchmark)
+ public static class BenchmarkState {
+ final Image Image = PngEncoderBenchmarkCompressionSpeedVsSize.createTestImage();
+ }
+
+ private static final Options OPTIONS = new OptionsBuilder().include(PngEncoderBenchmarkCompressionSpeedVsSize.class.getSimpleName() + ".*").shouldFailOnError(true).mode(Mode.Throughput)
+ .timeUnit(TimeUnit.SECONDS).threads(1).forks(1).warmupIterations(1).measurementIterations(1).warmupTime(TimeValue.seconds(2)).measurementTime(TimeValue.seconds(5)).build();
+
+ private static Image createTestImage() {
+ return PngEncoderTestUtil.readTestImageResource("png-encoder-logo.png");
+ }
+
+ @Benchmark
+ public void compressionLevel0(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 0);
+ }
+
+ @Benchmark
+ public void compressionLevel1(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 1);
+ }
+
+ @Benchmark
+ public void compressionLevel2(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 2);
+ }
+
+ @Benchmark
+ public void compressionLevel3(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 3);
+ }
+
+ @Benchmark
+ public void compressionLevel4(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 4);
+ }
+
+ @Benchmark
+ public void compressionLevel5(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 5);
+ }
+
+ @Benchmark
+ public void compressionLevel6(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 6);
+ }
+
+ @Benchmark
+ public void compressionLevel7(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 7);
+ }
+
+ @Benchmark
+ public void compressionLevel8(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 8);
+ }
+
+ @Benchmark
+ public void compressionLevel9(final BenchmarkState state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image, 9);
+ }
+
+ @Disabled("run manually")
+ @Test
+ public void runBenchmarkSize() {
+ final Image Image = PngEncoderBenchmarkCompressionSpeedVsSize.createTestImage();
+ for (int compressionLevel = 0; compressionLevel <= 9; compressionLevel++) {
+ final int fileSize = PngEncoderTestUtil.encodeWithPngEncoder(Image, compressionLevel);
+ String message = String.format("compressionLevel: %d fileSize: %d", compressionLevel, fileSize);
+ System.out.println(message);
+ }
+ }
+
+ @Disabled("run manually")
+ @Test
+ public void runBenchmarkSpeed() throws Exception {
+ new Runner(PngEncoderBenchmarkCompressionSpeedVsSize.OPTIONS).run();
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkPngEncoderVsImageIO.java b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkPngEncoderVsImageIO.java
new file mode 100644
index 0000000..d0a1087
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderBenchmarkPngEncoderVsImageIO.java
@@ -0,0 +1,77 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import java.awt.image.Image;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmark Mode Cnt Score Error Units
+ *
+ * PngEncoderBenchmarkPngEncoderVsImageIO.random1024x1024ImageIO thrpt 5.150 ops/s
+ * PngEncoderBenchmarkPngEncoderVsImageIO.random1024x1024PngEncoder thrpt 36.324 ops/s
+ * 36.324 / 5.150 = 7.1 times faster
+ *
+ * PngEncoderBenchmarkPngEncoderVsImageIO.logo2121x350ImageIO thrpt 24.857 ops/s
+ * PngEncoderBenchmarkPngEncoderVsImageIO.logo2121x350PngEncoder thrpt 127.034 ops/s
+ * 127.034 / 24.857 = 5.1 times faster
+ *
+ * PngEncoderBenchmarkPngEncoderVsImageIO.looklet4900x6000ImageIO thrpt 0.029 ops/s
+ * PngEncoderBenchmarkPngEncoderVsImageIO.looklet4900x6000PngEncoder thrpt 0.159 ops/s
+ * 0.159 / 0.029 = 5.5 times faster
+ */
+public class PngEncoderBenchmarkPngEncoderVsImageIO {
+ @State(Scope.Benchmark)
+ public static class BenchmarkStateLogo2121x350 {
+ final Image Image = PngEncoderTestUtil.readTestImageResource("png-encoder-logo.png");
+ }
+
+ @State(Scope.Benchmark)
+ public static class BenchmarkStateLooklet4900x6000 {
+ final Image Image = PngEncoderImageConverter.ensureType(PngEncoderTestUtil.readTestImageResource("looklet-look-scale6.png"), PngEncoderImageType.TYPE_INT_ARGB);
+ }
+
+ @State(Scope.Benchmark)
+ public static class BenchmarkStateRandom1024x1024 {
+ final Image Image = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB, 1024);
+ }
+
+ private static final Options OPTIONS = new OptionsBuilder().include(PngEncoderBenchmarkPngEncoderVsImageIO.class.getSimpleName() + ".*").shouldFailOnError(true).mode(Mode.Throughput)
+ .timeUnit(TimeUnit.SECONDS).threads(1).forks(1).warmupIterations(1).measurementIterations(1).warmupTime(TimeValue.seconds(2)).measurementTime(TimeValue.seconds(5)).build();
+
+ @Benchmark
+ public void logo2121x350ImageIO(final BenchmarkStateLogo2121x350 state) {
+ PngEncoderTestUtil.encodeWithImageIO(state.Image);
+ }
+
+ @Benchmark
+ public void logo2121x350PngEncoder(final BenchmarkStateLogo2121x350 state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image);
+ }
+
+ @Benchmark
+ public void looklet4900x6000ImageIO(final BenchmarkStateLooklet4900x6000 state) {
+ PngEncoderTestUtil.encodeWithImageIO(state.Image);
+ }
+
+ @Benchmark
+ public void looklet4900x6000PngEncoder(final BenchmarkStateLooklet4900x6000 state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image);
+ }
+
+ @Benchmark
+ public void random1024x1024ImageIO(final BenchmarkStateRandom1024x1024 state) {
+ PngEncoderTestUtil.encodeWithImageIO(state.Image);
+ }
+
+ @Benchmark
+ public void random1024x1024PngEncoder(final BenchmarkStateRandom1024x1024 state) {
+ PngEncoderTestUtil.encodeWithPngEncoder(state.Image);
+ }
+
+ @Disabled("run manually")
+ @Test
+ public void runBenchmark() throws Exception {
+ new Runner(PngEncoderBenchmarkPngEncoderVsImageIO.OPTIONS).run();
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageConverterTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageConverterTest.java
new file mode 100644
index 0000000..5997d38
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageConverterTest.java
@@ -0,0 +1,168 @@
+package test.atriasoft.pngencoder;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.Image;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+
+public class PngEncoderImageConverterTest {
+ private static void assertEquals(final Image actual, final Image expected) {
+ MatcherAssert.assertThat(actual.getWidth(), is(expected.getWidth()));
+ MatcherAssert.assertThat(actual.getHeight(), is(expected.getHeight()));
+ for (int y = 0; y < actual.getWidth(); y++) {
+ for (int x = 0; x < actual.getWidth(); x++) {
+ MatcherAssert.assertThat(actual.getRGB(x, y), is(expected.getRGB(x, y)));
+ }
+ }
+ }
+
+ @Test
+ public void copyTypeReturnsDifferentForDifferentType() {
+ Image original = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ Image ensured = PngEncoderImageConverter.copyType(original, PngEncoderImageType.TYPE_USHORT_GRAY);
+ MatcherAssert.assertThat(original, is(not(ensured)));
+ }
+
+ @Test
+ public void copyTypeReturnsDifferentForSameType() {
+ Image original = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ Image ensured = PngEncoderImageConverter.copyType(original, PngEncoderImageType.TYPE_INT_ARGB);
+ MatcherAssert.assertThat(original, is(not(ensured)));
+ }
+
+ @Test
+ public void createFrom3ByteBgr() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_3BYTE_BGR);
+ final byte[] data = PngEncoderImageConverter.getDataBufferByte(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFrom3ByteBgr(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFrom4ByteAbgr() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_4BYTE_ABGR);
+ final byte[] data = PngEncoderImageConverter.getDataBufferByte(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFrom4ByteAbgr(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFrom4ByteAbgrPre() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_4BYTE_ABGR_PRE);
+ final byte[] data = PngEncoderImageConverter.getDataBufferByte(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFrom4ByteAbgrPre(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromByteBinary() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_BYTE_BINARY);
+ final byte[] data = PngEncoderImageConverter.getDataBufferByte(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromByteBinary(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromByteGray() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_BYTE_GRAY);
+ final byte[] data = PngEncoderImageConverter.getDataBufferByte(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromByteGray(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromIntArgb() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ final int[] data = PngEncoderImageConverter.getDataBufferInt(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromIntArgb(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromIntArgbPre() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB_PRE);
+ final int[] data = PngEncoderImageConverter.getDataBufferInt(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromIntArgbPre(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromIntBgr() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_BGR);
+ final int[] data = PngEncoderImageConverter.getDataBufferInt(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromIntBgr(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromIntRgb() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_RGB);
+ final int[] data = PngEncoderImageConverter.getDataBufferInt(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromIntRgb(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromUshort555Rgb() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_USHORT_555_RGB);
+ final short[] data = PngEncoderImageConverter.getDataBufferUShort(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromUshort555Rgb(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromUshort565Rgb() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_USHORT_565_RGB);
+ final short[] data = PngEncoderImageConverter.getDataBufferUShort(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromUshort565Rgb(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void createFromUshortGray() {
+ final Image expected = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_USHORT_GRAY);
+ final short[] data = PngEncoderImageConverter.getDataBufferUShort(expected).getData();
+ final int width = expected.getWidth();
+ final int height = expected.getHeight();
+ final Image actual = PngEncoderImageConverter.createFromUshortGray(data, width, height);
+ PngEncoderImageConverterTest.assertEquals(actual, expected);
+ }
+
+ @Test
+ public void ensureTypeReturnsDifferentForDifferentType() {
+ Image original = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ Image ensured = PngEncoderImageConverter.ensureType(original, PngEncoderImageType.TYPE_USHORT_GRAY);
+ MatcherAssert.assertThat(original, is(not(ensured)));
+ }
+
+ @Test
+ public void ensureTypeReturnsSameForSameType() {
+ Image original = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ Image ensured = PngEncoderImageConverter.ensureType(original, PngEncoderImageType.TYPE_INT_ARGB);
+ MatcherAssert.assertThat(original, is(ensured));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageTypeTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageTypeTest.java
new file mode 100644
index 0000000..db3f073
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderBufferedImageTypeTest.java
@@ -0,0 +1,63 @@
+package test.atriasoft.pngencoder;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.Image;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PngEncoderImageTypeTest {
+ @Test
+ public void containsAllImageTypes1() {
+ new Image(1, 1, PngEncoderImageType.values().length - 1);
+ }
+
+ @Test
+ public void containsAllImageTypes2() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new Image(1, 1, PngEncoderImageType.values().length));
+ }
+
+ @Test
+ public void ToStringCombinesNameAndOrdinal() {
+ String actual = PngEncoderImageType.TYPE_INT_ARGB.toString();
+ String expected = "TYPE_INT_ARGB#2";
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void valueOfAllInOrdinalLoop() {
+ for (PngEncoderImageType expected : PngEncoderImageType.values()) {
+ PngEncoderImageType actual = PngEncoderImageType.valueOf(expected.ordinal());
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+ }
+
+ @Test
+ public void valueOfImage() {
+ Image Image = new Image(1, 1, Image.TYPE_INT_ARGB_PRE);
+ PngEncoderImageType actual = PngEncoderImageType.valueOf(Image);
+ PngEncoderImageType expected = PngEncoderImageType.TYPE_INT_ARGB_PRE;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void valueOfTypeCustom() {
+ PngEncoderImageType actual = PngEncoderImageType.valueOf(Image.TYPE_CUSTOM);
+ PngEncoderImageType expected = PngEncoderImageType.TYPE_CUSTOM;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void valueOfTypeIntArgb() {
+ PngEncoderImageType actual = PngEncoderImageType.valueOf(Image.TYPE_INT_ARGB);
+ PngEncoderImageType expected = PngEncoderImageType.TYPE_INT_ARGB;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterBufferPoolTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterBufferPoolTest.java
new file mode 100644
index 0000000..c0b0588
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterBufferPoolTest.java
@@ -0,0 +1,50 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderDeflaterBufferPoolTest {
+ @Test
+ public void bufferBytesLengthIsBufferMaxLength() {
+ final PngEncoderDeflaterBufferPool bufferPool = new PngEncoderDeflaterBufferPool(1337);
+ PngEncoderDeflaterBuffer borrowed = bufferPool.borrow();
+ final int actual = borrowed.bytes.length;
+ final int expected = 1337;
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void initialSizeIsZero() {
+ final PngEncoderDeflaterBufferPool bufferPool = new PngEncoderDeflaterBufferPool(1337);
+ final int actual = bufferPool.size();
+ final int expected = 0;
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void sizeIs1AfterBorrowingAndGivingBackTwice() {
+ final PngEncoderDeflaterBufferPool bufferPool = new PngEncoderDeflaterBufferPool(1337);
+
+ PngEncoderDeflaterBuffer borrowed1 = bufferPool.borrow();
+ borrowed1.giveBack();
+
+ PngEncoderDeflaterBuffer borrowed2 = bufferPool.borrow();
+ borrowed2.giveBack();
+
+ final int actual = bufferPool.size();
+ final int expected = 1;
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void sizeIsZeroAfterBorrowingTwiceAndNotGivingBack() {
+ final PngEncoderDeflaterBufferPool bufferPool = new PngEncoderDeflaterBufferPool(1337);
+ bufferPool.borrow();
+ bufferPool.borrow();
+ final int actual = bufferPool.size();
+ final int expected = 0;
+ assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceTest.java
new file mode 100644
index 0000000..9e59d70
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceTest.java
@@ -0,0 +1,17 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.ExecutorService;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderDeflaterExecutorServiceTest {
+ @Test
+ public void getInstanceReturnsSameInstance() {
+ ExecutorService expected = PngEncoderDeflaterExecutorService.getInstance();
+ ExecutorService actual = PngEncoderDeflaterExecutorService.getInstance();
+ assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactoryTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactoryTest.java
new file mode 100644
index 0000000..2ddaf44
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactoryTest.java
@@ -0,0 +1,37 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderDeflaterExecutorServiceThreadFactoryTest {
+ private static Thread newThread() {
+ PngEncoderDeflaterExecutorServiceThreadFactory threadFactory = new PngEncoderDeflaterExecutorServiceThreadFactory();
+ return threadFactory.newThread(() -> {});
+ }
+
+ @Test
+ public void daemonIsTrue() {
+ Thread thread = PngEncoderDeflaterExecutorServiceThreadFactoryTest.newThread();
+ boolean actual = thread.isDaemon();
+ boolean expected = true;
+
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void getInstanceReturnsSameInstance() {
+ PngEncoderDeflaterExecutorServiceThreadFactory expected = PngEncoderDeflaterExecutorServiceThreadFactory.getInstance();
+ PngEncoderDeflaterExecutorServiceThreadFactory actual = PngEncoderDeflaterExecutorServiceThreadFactory.getInstance();
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void nameIsCustom() {
+ Thread thread = PngEncoderDeflaterExecutorServiceThreadFactoryTest.newThread();
+ String actual = thread.getName();
+
+ assertThat(actual, is("PngEncoder Deflater (0)"));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterOutputStreamTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterOutputStreamTest.java
new file mode 100644
index 0000000..751c7e5
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterOutputStreamTest.java
@@ -0,0 +1,263 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.util.Collections;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiConsumer;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.InflaterOutputStream;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PngEncoderDeflaterOutputStreamTest {
+ private static class PngEncoderDeflaterBufferPoolAssertive extends PngEncoderDeflaterBufferPool {
+ private final Set borrowed = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ private final Set given = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
+ public PngEncoderDeflaterBufferPoolAssertive(final int bufferMaxLength) {
+ super(bufferMaxLength);
+ }
+
+ void assertThatGivenIsBorrowed() {
+ assertThat(this.given, is(this.borrowed));
+ }
+
+ @Override
+ PngEncoderDeflaterBuffer borrow() {
+ PngEncoderDeflaterBuffer buffer = super.borrow();
+ this.borrowed.add(buffer);
+ return buffer;
+ }
+
+ @Override
+ void giveBack(final PngEncoderDeflaterBuffer buffer) {
+ if (buffers.contains(buffer)) {
+ throw new IllegalArgumentException("Adding an already present buffer to pool is not allowed.");
+ }
+ this.given.add(buffer);
+ super.giveBack(buffer);
+ }
+ }
+
+ private static class RiggedOutputStream extends OutputStream {
+ private int count;
+ private final int countBytesToThrowException;
+
+ public RiggedOutputStream(final int countBytesToThrowException) {
+ this.countBytesToThrowException = countBytesToThrowException;
+ this.count = 0;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ this.count++;
+ if (this.count >= this.countBytesToThrowException) {
+ throw new IOException("This exception was generated for the purpose of testing.");
+ }
+ }
+ }
+
+ private static class RiggedPngEncoderDeflaterSegmentTask extends PngEncoderDeflaterSegmentTask {
+ private static final PngEncoderDeflaterBufferPool pool = new PngEncoderDeflaterBufferPool(1337);
+
+ public RiggedPngEncoderDeflaterSegmentTask() {
+ super(RiggedPngEncoderDeflaterSegmentTask.pool.borrow(), RiggedPngEncoderDeflaterSegmentTask.pool.borrow(), PngEncoder.DEFAULT_COMPRESSION_LEVEL, false);
+ }
+
+ @Override
+ public PngEncoderDeflaterSegmentResult get() {
+ throw new RuntimeException("This exception was generated for the purpose of testing.");
+ }
+ }
+
+ private static final BiConsumer MULTI_THREADED_DEFLATER = (bytes, outputStream) -> {
+ try (PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(outputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL,
+ PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL)) {
+ deflaterOutputStream.write(bytes);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ };
+
+ private static final int SEGMENT_MAX_LENGTH_ORIGINAL = 64 * 1024;
+
+ private static final BiConsumer SINGLE_THREADED_DEFLATER = (bytes, outputStream) -> {
+ try (DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(outputStream)) {
+ deflaterOutputStream.write(bytes);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ };
+
+ private static void assertThatBytesIsSameAfterDeflateAndInflate(final byte[] expected, final BiConsumer deflater) throws IOException {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ deflater.accept(expected, outputStream);
+ byte[] deflated = outputStream.toByteArray();
+ byte[] actual = PngEncoderDeflaterOutputStreamTest.inflate(deflated);
+ assertThat(actual.length, is(expected.length));
+ assertThat(actual, is(expected));
+ }
+
+ private static void assertThatBytesIsSameAfterDeflateAndInflateFast(final byte[] expected, final BiConsumer deflater) throws IOException {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ deflater.accept(expected, outputStream);
+ byte[] deflated = outputStream.toByteArray();
+ byte[] actual = PngEncoderDeflaterOutputStreamTest.inflate(deflated);
+ assertThat(actual.length, is(expected.length));
+ for (int i = 0; i < actual.length; i += 11) {
+ assertThat(actual[i], is(expected[i]));
+ }
+ }
+
+ private static byte[] createRandomBytes(final int length) {
+ Random random = new Random(12345);
+ byte[] randomBytes = new byte[length];
+ random.nextBytes(randomBytes);
+ return randomBytes;
+ }
+
+ private static byte[] inflate(final byte[] deflated) throws IOException {
+ ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+ try (InflaterOutputStream inflaterOutputStream = new InflaterOutputStream(byteArrayOutputStream)) {
+ inflaterOutputStream.write(deflated);
+ }
+ return byteArrayOutputStream.toByteArray();
+ }
+
+ @Test
+ public void assertiveBufferPool10Bytes() throws IOException {
+ PngEncoderDeflaterBufferPoolAssertive pool = new PngEncoderDeflaterBufferPoolAssertive(
+ PngEncoderDeflaterOutputStream.getSegmentMaxLengthDeflated(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL));
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(outputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL,
+ PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL, pool);
+ byte[] bytesToWrite = PngEncoderDeflaterOutputStreamTest.createRandomBytes(10);
+ deflaterOutputStream.write(bytesToWrite);
+ deflaterOutputStream.finish();
+ pool.assertThatGivenIsBorrowed();
+ }
+
+ @Test
+ public void assertiveBufferPoolManyBytes() throws IOException {
+ PngEncoderDeflaterBufferPoolAssertive pool = new PngEncoderDeflaterBufferPoolAssertive(
+ PngEncoderDeflaterOutputStream.getSegmentMaxLengthDeflated(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL));
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(outputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL,
+ PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL, pool);
+ byte[] bytesToWrite = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL * 2);
+ deflaterOutputStream.write(bytesToWrite);
+ deflaterOutputStream.finish();
+ pool.assertThatGivenIsBorrowed();
+ }
+
+ @Test
+ public void constructorThrowsIOExceptionOnWritingDeflateHeaderWithRiggedOutputStream() throws IOException {
+ RiggedOutputStream riggedOutputStream = new RiggedOutputStream(1);
+ assertThrows(IOException.class, () -> new PngEncoderDeflaterOutputStream(riggedOutputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL, SEGMENT_MAX_LENGTH_ORIGINAL));
+ }
+
+ @Test
+ public void deflateMultiThreaded300SegmentsToTestThreadSafety() throws Exception {
+ byte[] expected = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL * 300);
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflateFast(expected, PngEncoderDeflaterOutputStreamTest.MULTI_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateMultiThreadedJustFiveBytes() throws Exception {
+ byte[] expected = { 1, 2, 3, 4, 5 };
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.MULTI_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateMultiThreadedOneSegment() throws Exception {
+ byte[] expected = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL / 2);
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.MULTI_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateMultiThreadedTwoSegmentsToTestSegmentBoundary() throws Exception {
+ byte[] expected = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL * 2);
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.MULTI_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateSingleThreadedJustFiveBytes() throws Exception {
+ byte[] expected = { 1, 2, 3, 4, 5 };
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.SINGLE_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateSingleThreadedOneSegment() throws Exception {
+ byte[] expected = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL / 2);
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.SINGLE_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void deflateSingleThreadedTwoSegmentsToTestSegmentBoundary() throws Exception {
+ byte[] expected = PngEncoderDeflaterOutputStreamTest.createRandomBytes(PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL * 2);
+ PngEncoderDeflaterOutputStreamTest.assertThatBytesIsSameAfterDeflateAndInflate(expected, PngEncoderDeflaterOutputStreamTest.SINGLE_THREADED_DEFLATER);
+ }
+
+ @Test
+ public void finishThrowsIOExceptionOnJoiningWithRiggedOutputStream() throws IOException {
+ RiggedOutputStream riggedOutputStream = new RiggedOutputStream(3);
+ PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(riggedOutputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL,
+ PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL);
+ byte[] bytesToWrite = PngEncoderDeflaterOutputStreamTest.createRandomBytes(10);
+ deflaterOutputStream.write(bytesToWrite);
+ assertThrows(IOException.class, deflaterOutputStream::finish);
+ }
+
+ @Test
+ public void finishThrowsIOExceptionOnJoiningWithRiggedPngEncoderDeflaterSegmentTask() throws IOException {
+ ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+ PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(byteArrayOutputStream, PngEncoder.DEFAULT_COMPRESSION_LEVEL,
+ PngEncoderDeflaterOutputStreamTest.SEGMENT_MAX_LENGTH_ORIGINAL);
+ deflaterOutputStream.submitTask(new RiggedPngEncoderDeflaterSegmentTask());
+ assertThrows(IOException.class, deflaterOutputStream::finish);
+ }
+
+ @Test
+ public void getSegmentMaxLengthOriginalDoesNotIncreaseImmediatelyOverMin() {
+ final int actual = PngEncoderDeflaterOutputStream.getSegmentMaxLengthOriginal(PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_ORIGINAL_MIN + 1);
+ final int expected = PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_ORIGINAL_MIN;
+
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void getSegmentMaxLengthOriginalRespectsMin() {
+ final int actual = PngEncoderDeflaterOutputStream.getSegmentMaxLengthOriginal(1);
+ final int expected = PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_ORIGINAL_MIN;
+
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void segmentMaxLengthDeflatedGreaterThanSegmentMaxLengthOriginal() {
+ final int segmentMaxLengthOriginal = 64 * 1024;
+ final int segmentMaxLengthDeflated = PngEncoderDeflaterOutputStream.getSegmentMaxLengthDeflated(segmentMaxLengthOriginal);
+
+ assertThat(segmentMaxLengthDeflated, is(greaterThan(segmentMaxLengthOriginal)));
+ }
+
+ @Test
+ public void segmentMaxLengthDictionaryIsExactly32k() {
+ assertThat(PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_DICTIONARY, is(32 * 1024));
+ }
+
+ @Test
+ public void segmentMaxLengthOriginalMinGreaterThanSegmentMaxLengthDictionary() {
+ assertThat(PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_ORIGINAL_MIN, is(greaterThan(PngEncoderDeflaterOutputStream.SEGMENT_MAX_LENGTH_DICTIONARY)));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterSegmentResultTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterSegmentResultTest.java
new file mode 100644
index 0000000..da0fc5d
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterSegmentResultTest.java
@@ -0,0 +1,33 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.zip.Adler32;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderDeflaterSegmentResultTest {
+ @Test
+ public void combineAdler32() {
+ byte[] bytesAll = { 1, 2, 3, 4, 5, 6, 7 };
+ byte[] bytes1 = { 1, 2, 3 };
+ byte[] bytes2 = { 4, 5, 6, 7 };
+
+ Adler32 bytes1Adler32 = new Adler32();
+ bytes1Adler32.update(bytes1);
+ long adler1 = bytes1Adler32.getValue();
+
+ Adler32 bytes2Adler32 = new Adler32();
+ bytes2Adler32.update(bytes2);
+ long adler2 = bytes2Adler32.getValue();
+
+ long actual = PngEncoderDeflaterSegmentResult.combine(adler1, adler2, bytes2.length);
+
+ Adler32 bytesAllAdler32 = new Adler32();
+ bytesAllAdler32.update(bytesAll);
+ long expected = bytesAllAdler32.getValue();
+
+ assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflaterTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflaterTest.java
new file mode 100644
index 0000000..a595409
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflaterTest.java
@@ -0,0 +1,27 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.zip.Deflater;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.sameInstance;
+
+public class PngEncoderDeflaterThreadLocalDeflaterTest {
+ @Test
+ public void assertThatAllCompressionLevelInstancesAreGettable() {
+ for (int compressionLevel = -1; compressionLevel <= 9; compressionLevel++) {
+ Deflater deflater = PngEncoderDeflaterThreadLocalDeflater.getInstance(1);
+ assertThat(deflater, is(notNullValue(Deflater.class)));
+ }
+ }
+
+ @Test
+ public void sameCompressionLevelReturnsSameInstance() {
+ final Deflater expected = PngEncoderDeflaterThreadLocalDeflater.getInstance(1);
+ final Deflater actual = PngEncoderDeflaterThreadLocalDeflater.getInstance(1);
+ assertThat(actual, is(sameInstance(expected)));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderIdatChunksOutputStreamTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderIdatChunksOutputStreamTest.java
new file mode 100644
index 0000000..5ec6428
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderIdatChunksOutputStreamTest.java
@@ -0,0 +1,39 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderIdatChunksOutputStreamTest {
+ @Test
+ public void assertThatIdatChunkContainsIdatStringBytes() throws IOException {
+ final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+ final PngEncoderIdatChunksOutputStream idatChunksOutputStream = new PngEncoderIdatChunksOutputStream(byteArrayOutputStream);
+ final byte[] content = { 1, 2, 3 };
+ idatChunksOutputStream.write(content);
+ idatChunksOutputStream.flush();
+ final byte[] idatChunkBytes = byteArrayOutputStream.toByteArray();
+ final byte[] actual = Arrays.copyOfRange(idatChunkBytes, 4, 8);
+ final byte[] expected = PngEncoderIdatChunksOutputStream.IDAT_BYTES;
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void assertThatIdatChunkStartsWithContentLength() throws IOException {
+ final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+ final PngEncoderIdatChunksOutputStream idatChunksOutputStream = new PngEncoderIdatChunksOutputStream(byteArrayOutputStream);
+ final byte[] content = { 1, 2, 3 };
+ idatChunksOutputStream.write(content);
+ idatChunksOutputStream.flush();
+ final byte[] idatChunkBytes = byteArrayOutputStream.toByteArray();
+ final int actual = ByteBuffer.wrap(idatChunkBytes, 0, 4).asIntBuffer().get();
+ final int expected = content.length;
+ assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderLogicTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderLogicTest.java
new file mode 100644
index 0000000..08e112e
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderLogicTest.java
@@ -0,0 +1,101 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.util.zip.CRC32;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PngEncoderLogicTest {
+ public static final String VALID_CHUNK_TYPE = "IDAT";
+
+ private static int getSimpleCrc(final byte[] b) {
+ CRC32 crc32 = new CRC32();
+ crc32.update(b);
+ return (int) crc32.getValue();
+ }
+
+ private static byte[] intToBytes(final int val) {
+ ByteBuffer byteBuffer = ByteBuffer.allocate(4);
+ byteBuffer.putInt(val);
+ return byteBuffer.array();
+ }
+
+ @Test
+ public void testAsChunkAdds12Bytes() {
+ byte[] data = { 5 };
+ byte[] chunk = PngEncoderLogic.asChunk(PngEncoderLogicTest.VALID_CHUNK_TYPE, data);
+
+ int expected = 13;
+ int actual = chunk.length;
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testAsChunkCrcIsCalculatedFromTypeAndData() {
+ byte[] data = { 5, 7 };
+ byte[] chunk = PngEncoderLogic.asChunk(PngEncoderLogicTest.VALID_CHUNK_TYPE, data);
+
+ ByteBuffer byteBuffer = ByteBuffer.allocate(6);
+ byteBuffer.mark();
+ byteBuffer.put(PngEncoderLogicTest.VALID_CHUNK_TYPE.getBytes());
+ byteBuffer.put(data);
+ byteBuffer.reset();
+ int expectedCrc = PngEncoderLogic.getCrc32(byteBuffer);
+
+ byte[] expected = PngEncoderLogicTest.intToBytes(expectedCrc);
+ assertThat(chunk[10], is(expected[0]));
+ assertThat(chunk[11], is(expected[1]));
+ assertThat(chunk[12], is(expected[2]));
+ assertThat(chunk[13], is(expected[3]));
+ }
+
+ @Test
+ public void testAsChunkFirstByteIsSizeOfData() {
+ byte[] data = { 5, 7 };
+ byte[] chunk = PngEncoderLogic.asChunk(PngEncoderLogicTest.VALID_CHUNK_TYPE, data);
+
+ byte[] expected = PngEncoderLogicTest.intToBytes(data.length);
+ assertThat(chunk[0], is(expected[0]));
+ assertThat(chunk[1], is(expected[1]));
+ assertThat(chunk[2], is(expected[2]));
+ assertThat(chunk[3], is(expected[3]));
+ }
+
+ @Test
+ public void testAsChunkNullBytes() {
+ assertThrows(NullPointerException.class, () -> PngEncoderLogic.asChunk(VALID_CHUNK_TYPE, null));
+ }
+
+ @Test
+ public void testAsChunkTypeInvalid() {
+ assertThrows(IllegalArgumentException.class, () -> PngEncoderLogic.asChunk("chunk types must be four characters long", new byte[1]));
+ }
+
+ @Test
+ public void testAsChunkTypeNull() {
+ assertThrows(NullPointerException.class, () -> PngEncoderLogic.asChunk(null, new byte[1]));
+ }
+
+ @Test
+ public void testCrcWithPartialByteBuffer() {
+ byte[] b = { 5 };
+
+ ByteBuffer byteBuffer = ByteBuffer.allocate(20);
+
+ byteBuffer.put("garbage".getBytes());
+
+ byteBuffer.mark();
+ ByteBuffer slice = byteBuffer.slice().asReadOnlyBuffer();
+ byteBuffer.put(b);
+ slice.limit(b.length);
+ byteBuffer.put("more garbage".getBytes());
+
+ int actual = PngEncoderLogic.getCrc32(slice);
+ int expected = PngEncoderLogicTest.getSimpleCrc(b);
+ assertThat(actual, is(expected));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensionsTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensionsTest.java
new file mode 100644
index 0000000..5d7a2bc
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensionsTest.java
@@ -0,0 +1,36 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class PngEncoderPhysicalPixelDimensionsTest {
+
+ @Test
+ public void aspectRatio() {
+ PngEncoderPhysicalPixelDimensions physicalPixelDimensions = PngEncoderPhysicalPixelDimensions.aspectRatio(2, 3);
+
+ assertThat(physicalPixelDimensions.getPixelsPerUnitX(), is(2));
+ assertThat(physicalPixelDimensions.getPixelsPerUnitY(), is(3));
+ assertThat(physicalPixelDimensions.getUnit(), is(PngEncoderPhysicalPixelDimensions.Unit.UNKNOWN));
+ }
+
+ @Test
+ public void dpi() {
+ PngEncoderPhysicalPixelDimensions physicalPixelDimensions = PngEncoderPhysicalPixelDimensions.dotsPerInch(72);
+
+ assertThat(physicalPixelDimensions.getPixelsPerUnitX(), is(2835));
+ assertThat(physicalPixelDimensions.getPixelsPerUnitY(), is(2835));
+ assertThat(physicalPixelDimensions.getUnit(), is(PngEncoderPhysicalPixelDimensions.Unit.METER));
+ }
+
+ @Test
+ public void pixelsPerMeter() {
+ PngEncoderPhysicalPixelDimensions physicalPixelDimensions = PngEncoderPhysicalPixelDimensions.pixelsPerMeter(2835);
+
+ assertThat(physicalPixelDimensions.getPixelsPerUnitX(), is(2835));
+ assertThat(physicalPixelDimensions.getPixelsPerUnitY(), is(2835));
+ assertThat(physicalPixelDimensions.getUnit(), is(PngEncoderPhysicalPixelDimensions.Unit.METER));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderScanlineUtilTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderScanlineUtilTest.java
new file mode 100644
index 0000000..acbbecd
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderScanlineUtilTest.java
@@ -0,0 +1,61 @@
+package test.atriasoft.pngencoder;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.Image;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+
+public class PngEncoderScanlineUtilTest {
+ private void assertThatScanlineOfTestImageEqualsIntRgbOrArgb(final PngEncoderImageType type, final boolean alpha) {
+ final Image Image = PngEncoderTestUtil.createTestImage(type);
+ final Image ImageEnsured = PngEncoderImageConverter.ensureType(Image, alpha ? PngEncoderImageType.TYPE_INT_ARGB : PngEncoderImageType.TYPE_INT_RGB);
+ final byte[] actual = PngEncoderScanlineUtil.get(Image);
+ final byte[] expected = PngEncoderScanlineUtil.get(ImageEnsured);
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void get3ByteBgr() {
+ assertThatScanlineOfTestImageEqualsIntRgbOrArgb(PngEncoderImageType.TYPE_3BYTE_BGR, false);
+ }
+
+ @Test
+ public void get4ByteAbgr() {
+ assertThatScanlineOfTestImageEqualsIntRgbOrArgb(PngEncoderImageType.TYPE_4BYTE_ABGR, true);
+ }
+
+ @Test
+ public void getByteGray() {
+ assertThatScanlineOfTestImageEqualsIntRgbOrArgb(PngEncoderImageType.TYPE_BYTE_GRAY, false);
+ }
+
+ @Test
+ public void getIntArgbSize() {
+ final Image Image = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_ARGB);
+ final byte[] data = PngEncoderScanlineUtil.get(Image);
+ final int actual = data.length;
+ final int expected = Image.getHeight() * (Image.getWidth() * 4 + 1);
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void getIntBgr() {
+ assertThatScanlineOfTestImageEqualsIntRgbOrArgb(PngEncoderImageType.TYPE_INT_BGR, false);
+ }
+
+ @Test
+ public void getIntRgbSize() {
+ final Image Image = PngEncoderTestUtil.createTestImage(PngEncoderImageType.TYPE_INT_RGB);
+ final byte[] data = PngEncoderScanlineUtil.get(Image);
+ final int actual = data.length;
+ final int expected = Image.getHeight() * (Image.getWidth() * 3 + 1);
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void getUshortGray() {
+ assertThatScanlineOfTestImageEqualsIntRgbOrArgb(PngEncoderImageType.TYPE_USHORT_GRAY, false);
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderTest.java
new file mode 100644
index 0000000..78d455b
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderTest.java
@@ -0,0 +1,221 @@
+package test.atriasoft.pngencoder;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.w3c.dom.Element;
+
+import java.awt.image.Image;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.stream.IntStream;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.metadata.IIOMetadata;
+import javax.imageio.metadata.IIOMetadataNode;
+import javax.imageio.stream.ImageInputStream;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PngEncoderTest {
+ private static final int BLACK = 0xFF000000;
+ private static final int BLUE = 0xFF0000FF;
+ private static final int GREEN = 0XFF00FF00;
+ private static final Image ONE_PIXEL = PngEncoderImageConverter.createFromIntArgb(new int[1], 1, 1);
+ private static final int RED = 0xFFFF0000;
+
+ private static final int WHITE = 0xFFFFFFFF;
+
+ public static IIOMetadata getImageMetaDataWithImageIO(final byte[] filesBytes) throws IOException {
+ try (ByteArrayInputStream inputStream = new ByteArrayInputStream(filesBytes); ImageInputStream input = ImageIO.createImageInputStream(inputStream)) {
+ Iterator readers = ImageIO.getImageReaders(input);
+ ImageReader reader = readers.next();
+
+ reader.setInput(input);
+ return reader.getImageMetadata(0);
+ }
+ }
+
+ public static Image readWithImageIO(final byte[] filesBytes) throws IOException {
+ try (ByteArrayInputStream bais = new ByteArrayInputStream(filesBytes)) {
+ return ImageIO.read(bais);
+ }
+ }
+
+ private static int[] readWithImageIOgetRGB(final byte[] fileBytes) throws IOException {
+ Image Image = PngEncoderTest.readWithImageIO(fileBytes);
+
+ int width = Image.getWidth();
+ int height = Image.getHeight();
+ int[] argbImage = new int[width * height];
+
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ argbImage[y * width + x] = Image.getRGB(x, y);
+ }
+ }
+
+ return argbImage;
+ }
+
+ static IntStream validCompressionLevels() {
+ // Compression level value must be between -1 and 9 inclusive.
+ return IntStream.rangeClosed(-1, 9);
+ }
+
+ @ParameterizedTest()
+ @ValueSource(ints = { -2, 10 })
+ public void invalidCompressionLevel(final int compressionLevel) {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new PngEncoder().withCompressionLevel(compressionLevel));
+ }
+
+ @Test
+ public void testEncode() {
+ byte[] bytes = new PngEncoder().withImage(PngEncoderTest.ONE_PIXEL).withCompressionLevel(1).toBytes();
+
+ int pngHeaderLength = PngEncoderLogic.FILE_BEGINNING.length;
+ int ihdrLength = 25; // length(4)+"IHDR"(4)+values(13)+crc(4)
+ int idatLength = 23; // length(4)+"IDAT"(4)+compressed scanline(11)+crc(4)
+ int iendLength = PngEncoderLogic.FILE_ENDING.length;
+
+ int expected = pngHeaderLength + ihdrLength + idatLength + iendLength;
+ int actual = bytes.length;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeAndReadBlackTransparency() throws IOException {
+ int width = 0xFF;
+ int height = 1;
+ int[] image = new int[width];
+ for (int x = 0; x < width; x++) {
+ image[x] = x << 24;
+ }
+ Image Image = PngEncoderImageConverter.createFromIntArgb(image, width, height);
+ byte[] bytes = new PngEncoder().withImage(Image).withCompressionLevel(1).toBytes();
+
+ int[] actual = PngEncoderTest.readWithImageIOgetRGB(bytes);
+ int[] expected = image;
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeAndReadOpaque() throws IOException {
+ int width = 3;
+ int height = 2;
+ int[] image = { PngEncoderTest.WHITE, PngEncoderTest.BLACK, PngEncoderTest.RED, PngEncoderTest.GREEN, PngEncoderTest.WHITE, PngEncoderTest.BLUE };
+ Image Image = PngEncoderImageConverter.createFromIntArgb(image, width, height);
+ byte[] bytes = new PngEncoder().withImage(Image).withCompressionLevel(1).toBytes();
+
+ int[] actual = PngEncoderTest.readWithImageIOgetRGB(bytes);
+ int[] expected = image;
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeAndReadRedTransparency() throws IOException {
+ int width = 0xFF;
+ int height = 1;
+ int[] image = new int[width];
+ for (int x = 0; x < width; x++) {
+ int pixel = (x << 24) + (x << 16);
+ image[x] = pixel;
+ }
+ Image Image = PngEncoderImageConverter.createFromIntArgb(image, width, height);
+ byte[] bytes = new PngEncoder().withImage(Image).withCompressionLevel(1).toBytes();
+
+ int[] actual = PngEncoderTest.readWithImageIOgetRGB(bytes);
+ int[] expected = image;
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeWithoutImage() {
+ // Document the fact that, at the moment, attempting to encode without providing an
+ // image throws NullPointException.
+ PngEncoder emptyEncoder = new PngEncoder();
+ Assertions.assertThrows(NullPointerException.class, () -> emptyEncoder.toBytes());
+
+ PngEncoder encoderWithoutImage = new PngEncoder().withCompressionLevel(9).withMultiThreadedCompressionEnabled(true);
+ Assertions.assertThrows(NullPointerException.class, () -> encoderWithoutImage.toBytes());
+ }
+
+ @Test
+ public void testEncodeWithPhysicalPixelDimensions() {
+ byte[] bytes = new PngEncoder().withImage(PngEncoderTest.ONE_PIXEL).withCompressionLevel(1).withPhysicalPixelDimensions(PngEncoderPhysicalPixelDimensions.dotsPerInch(300)).toBytes();
+
+ int pngHeaderLength = PngEncoderLogic.FILE_BEGINNING.length;
+ int ihdrLength = 25; // length(4)+"IHDR"(4)+values(13)+crc(4)
+ int physLength = 21; // length(4)+"pHYs"(4)+values(9)+crc(4)
+ int idatLength = 23; // length(4)+"IDAT"(4)+compressed scanline(11)+crc(4)
+ int iendLength = PngEncoderLogic.FILE_ENDING.length;
+
+ int expected = pngHeaderLength + ihdrLength + physLength + idatLength + iendLength;
+ int actual = bytes.length;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeWithPhysicalPixelDimensionsAndReadMetadata() throws IOException {
+ int dotsPerInch = 150;
+ byte[] bytes = new PngEncoder().withImage(PngEncoderTest.ONE_PIXEL).withCompressionLevel(1).withPhysicalPixelDimensions(PngEncoderPhysicalPixelDimensions.dotsPerInch(dotsPerInch)).toBytes();
+
+ IIOMetadata metadata = PngEncoderTest.getImageMetaDataWithImageIO(bytes);
+ IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree("javax_imageio_1.0");
+ float horizontalPixelSize = Float.parseFloat(((Element) root.getElementsByTagName("HorizontalPixelSize").item(0)).getAttribute("value"));
+ float verticalPixelSize = Float.parseFloat(((Element) root.getElementsByTagName("VerticalPixelSize").item(0)).getAttribute("value"));
+
+ // Standard metadata contains the width/height of a pixel in millimeters
+ float mmPerInch = 25.4f;
+ MatcherAssert.assertThat(Math.round(mmPerInch / horizontalPixelSize), is(dotsPerInch));
+ MatcherAssert.assertThat(Math.round(mmPerInch / verticalPixelSize), is(dotsPerInch));
+ }
+
+ @Test
+ public void testEncodeWithSrgb() {
+ byte[] bytes = new PngEncoder().withImage(PngEncoderTest.ONE_PIXEL).withCompressionLevel(1).withSrgbRenderingIntent(PngEncoderSrgbRenderingIntent.PERCEPTUAL).toBytes();
+
+ int pngHeaderLength = PngEncoderLogic.FILE_BEGINNING.length;
+ int ihdrLength = 25; // length(4)+"IHDR"(4)+values(13)+crc(4)
+ int srgbLength = 13; // length(4)+"sRGB"(4)+value(1)+crc(4)
+ int gamaLength = 16; // length(4)+"gAMA"(4)+value(4)+crc(4)
+ int chrmLength = 44; // length(4)+"sRGB"(4)+value(32)+crc(4)
+ int idatLength = 23; // length(4)+"IDAT"(4)+compressed scanline(11)+crc(4)
+ int iendLength = PngEncoderLogic.FILE_ENDING.length;
+
+ int expected = pngHeaderLength + ihdrLength + srgbLength + gamaLength + chrmLength + idatLength + iendLength;
+ int actual = bytes.length;
+
+ MatcherAssert.assertThat(actual, is(expected));
+ }
+
+ @Test
+ public void testEncodeWithSrgbAndReadMetadata() throws IOException {
+ int width = 3;
+ int height = 2;
+ int[] image = { PngEncoderTest.WHITE, PngEncoderTest.BLACK, PngEncoderTest.RED, PngEncoderTest.GREEN, PngEncoderTest.WHITE, PngEncoderTest.BLUE };
+ Image Image = PngEncoderImageConverter.createFromIntArgb(image, width, height);
+ byte[] bytes = new PngEncoder().withImage(Image).withCompressionLevel(1).withSrgbRenderingIntent(PngEncoderSrgbRenderingIntent.PERCEPTUAL).toBytes();
+
+ IIOMetadata metadata = PngEncoderTest.getImageMetaDataWithImageIO(bytes);
+ IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(metadata.getNativeMetadataFormatName());
+
+ MatcherAssert.assertThat(root.getElementsByTagName("sRGB").getLength(), is(1));
+ MatcherAssert.assertThat(root.getElementsByTagName("cHRM").getLength(), is(1));
+ MatcherAssert.assertThat(root.getElementsByTagName("gAMA").getLength(), is(1));
+ }
+
+ @ParameterizedTest()
+ @MethodSource("validCompressionLevels")
+ public void validCompressionLevel(final int compressionLevel) {
+ Assertions.assertDoesNotThrow(() -> new PngEncoder().withCompressionLevel(compressionLevel));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderTestUtil.java b/test/src/test/atriasoft/pngencoder/PngEncoderTestUtil.java
new file mode 100644
index 0000000..c8e58d8
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderTestUtil.java
@@ -0,0 +1,59 @@
+package test.atriasoft.pngencoder;
+
+import java.awt.image.Image;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.util.Random;
+import javax.imageio.ImageIO;
+
+class PngEncoderTestUtil {
+ private static final int DEFAULT_SIDE = 128;
+
+ private static final OutputStream NULL_OUTPUT_STREAM = new NullOutputStream();
+ private static final Random RANDOM = new Random();
+
+ static Image createTestImage(final PngEncoderImageType type) {
+ return PngEncoderTestUtil.createTestImage(type, PngEncoderTestUtil.DEFAULT_SIDE);
+ }
+
+ static Image createTestImage(final PngEncoderImageType type, final int side) {
+ final Image Image = new Image(side, side, PngEncoderImageType.TYPE_INT_ARGB.ordinal());
+ for (int y = 0; y < Image.getHeight(); y++) {
+ for (int x = 0; x < Image.getWidth(); x++) {
+ int a = PngEncoderTestUtil.RANDOM.nextInt(256);
+ int r = PngEncoderTestUtil.RANDOM.nextInt(256);
+ int g = PngEncoderTestUtil.RANDOM.nextInt(256);
+ int b = PngEncoderTestUtil.RANDOM.nextInt(256);
+ int argb = a << 24 | r << 16 | g << 8 | b;
+ Image.setRGB(x, y, argb);
+ }
+ }
+ return PngEncoderImageConverter.ensureType(Image, type);
+ }
+
+ static void encodeWithImageIO(final Image Image) {
+ try {
+ ImageIO.write(Image, "png", PngEncoderTestUtil.NULL_OUTPUT_STREAM);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static int encodeWithPngEncoder(final Image Image) {
+ return new PngEncoder().withImage(Image).toStream(PngEncoderTestUtil.NULL_OUTPUT_STREAM);
+ }
+
+ static int encodeWithPngEncoder(final Image Image, final int compressionLevel) {
+ return new PngEncoder().withImage(Image).withCompressionLevel(compressionLevel).toStream(PngEncoderTestUtil.NULL_OUTPUT_STREAM);
+ }
+
+ static Image readTestImageResource(final String name) {
+ try (InputStream inputStream = PngEncoderTestUtil.class.getResourceAsStream("/" + name)) {
+ return ImageIO.read(inputStream);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read test image resource: " + name, e);
+ }
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/PngEncoderVerificationUtilTest.java b/test/src/test/atriasoft/pngencoder/PngEncoderVerificationUtilTest.java
new file mode 100644
index 0000000..cc345f7
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/PngEncoderVerificationUtilTest.java
@@ -0,0 +1,37 @@
+package test.atriasoft.pngencoder;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PngEncoderVerificationUtilTest {
+ @Test
+ public void verifyChunkTypeAcceptsIDAT() {
+ PngEncoderVerificationUtil.verifyChunkType("IDAT");
+ }
+
+ @Test
+ public void verifyChunkTypeRejectsLorem() {
+ assertThrows(IllegalArgumentException.class, () -> PngEncoderVerificationUtil.verifyChunkType("Lorem"));
+ }
+
+ @Test
+ public void verifyCompressionLevelAcceptsMinusOne() {
+ PngEncoderVerificationUtil.verifyCompressionLevel(-1);
+ }
+
+ @Test
+ public void verifyCompressionLevelAcceptsNine() {
+ PngEncoderVerificationUtil.verifyCompressionLevel(9);
+ }
+
+ @Test
+ public void verifyCompressionLevelRejectsMinusTwo() {
+ assertThrows(IllegalArgumentException.class, () -> PngEncoderVerificationUtil.verifyCompressionLevel(-2));
+ }
+
+ @Test
+ public void verifyCompressionLevelRejectsTen() {
+ assertThrows(IllegalArgumentException.class, () -> PngEncoderVerificationUtil.verifyCompressionLevel(10));
+ }
+}
diff --git a/test/src/test/atriasoft/pngencoder/Timing.java b/test/src/test/atriasoft/pngencoder/Timing.java
new file mode 100644
index 0000000..97c5879
--- /dev/null
+++ b/test/src/test/atriasoft/pngencoder/Timing.java
@@ -0,0 +1,12 @@
+package test.atriasoft.pngencoder;
+
+class Timing {
+ private static long previously = 0;
+
+ static void message(final String message) {
+ long now = System.currentTimeMillis();
+ long delta = Timing.previously > 0 ? now - Timing.previously : 0;
+ Timing.previously = now;
+ System.out.println("[" + delta + "] " + message);
+ }
+}