diff --git a/src/main/org/atriasoft/pngencoder/PngEncoder.java b/src/main/org/atriasoft/pngencoder/PngEncoder.java new file mode 100644 index 0000000..bffed89 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderCountingOutputStream.java b/src/main/org/atriasoft/pngencoder/PngEncoderCountingOutputStream.java new file mode 100644 index 0000000..9d81519 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterBuffer.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterBuffer.java new file mode 100644 index 0000000..ed1b0ac --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterBufferPool.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterBufferPool.java new file mode 100644 index 0000000..7a2dfdc --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorService.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorService.java new file mode 100644 index 0000000..4c4d838 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactory.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterExecutorServiceThreadFactory.java new file mode 100644 index 0000000..e99a178 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterOutputStream.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterOutputStream.java new file mode 100644 index 0000000..2a01137 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentResult.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentResult.java new file mode 100644 index 0000000..cc40a94 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentTask.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterSegmentTask.java new file mode 100644 index 0000000..04cf6db --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflater.java b/src/main/org/atriasoft/pngencoder/PngEncoderDeflaterThreadLocalDeflater.java new file mode 100644 index 0000000..f795bdc --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderIdatChunksOutputStream.java b/src/main/org/atriasoft/pngencoder/PngEncoderIdatChunksOutputStream.java new file mode 100644 index 0000000..817bb4b --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderLogic.java b/src/main/org/atriasoft/pngencoder/PngEncoderLogic.java new file mode 100644 index 0000000..3de5403 --- /dev/null +++ b/src/main/org/atriasoft/pngencoder/PngEncoderLogic.java @@ -0,0 +1,140 @@ +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.pngConvertInByteBufferRGBA(bufferedImage); + } else { + scanlineBytes = ToolImage.pngConvertInByteBufferRGB(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(); + //deflaterOutputStream.close(); + } else { + PngEncoderDeflaterOutputStream deflaterOutputStream = new PngEncoderDeflaterOutputStream(idatChunksOutputStream, compressionLevel, segmentMaxLengthOriginal); + deflaterOutputStream.write(scanlineBytes); + deflaterOutputStream.finish(); + //deflaterOutputStream.close(); + } + + 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/main/org/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensions.java b/src/main/org/atriasoft/pngencoder/PngEncoderPhysicalPixelDimensions.java new file mode 100644 index 0000000..97f4157 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderSrgbRenderingIntent.java b/src/main/org/atriasoft/pngencoder/PngEncoderSrgbRenderingIntent.java new file mode 100644 index 0000000..1340972 --- /dev/null +++ b/src/main/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/main/org/atriasoft/pngencoder/PngEncoderVerificationUtil.java b/src/main/org/atriasoft/pngencoder/PngEncoderVerificationUtil.java new file mode 100644 index 0000000..4981ec6 --- /dev/null +++ b/src/main/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() {} +}