[FEAT] add removed sources

This commit is contained in:
Edouard DUPIN 2025-07-08 22:20:41 +02:00
parent 38cb1e09ed
commit 27033472c9
15 changed files with 1045 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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<PngEncoderDeflaterBuffer> 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();
}
}

View File

@ -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() {}
}

View File

@ -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;
}
}

View File

@ -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<CompletableFuture<PngEncoderDeflaterSegmentResult>> 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<PngEncoderDeflaterSegmentResult> 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<PngEncoderDeflaterSegmentResult> 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);
}
}

View File

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

View File

@ -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<PngEncoderDeflaterSegmentResult> {
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);
}
}

View File

@ -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<PngEncoderDeflaterThreadLocalDeflater> 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;
}
}

View File

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

View File

@ -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() {}
}

View File

@ -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 <a href="https://www.w3.org/TR/PNG/#11pHYs">https://www.w3.org/TR/PNG/#11pHYs</a>
*/
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;
}
}

View File

@ -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;
}
}

View File

@ -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() {}
}