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); + } +}