实验性支持 APNG 图片 (#4205)

This commit is contained in:
Glavo
2025-08-06 16:10:45 +08:00
committed by GitHub
parent f945a85441
commit 3118c87c65
62 changed files with 4924 additions and 101 deletions

View File

@@ -95,6 +95,11 @@ tasks.withType<JavaCompile> {
targetCompatibility = "11"
}
tasks.checkstyleMain {
// Third-party code is not checked
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
@@ -116,7 +121,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*","freebsd-*", "linux-*", "darwin-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }

View File

@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.*;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
@@ -60,8 +59,9 @@ import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.DataUri;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
@@ -75,24 +75,16 @@ import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.ref.WeakReference;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@@ -169,7 +161,7 @@ public final class FXUtils {
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
public static final List<String> IMAGE_EXTENSIONS = Lang.immutableListOf(
"png", "jpg", "jpeg", "bmp", "gif", "webp"
"png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
@@ -913,60 +905,53 @@ public final class FXUtils {
stage.getIcons().add(newBuiltinImage(icon));
}
private static Image loadWebPImage(InputStream input) throws IOException {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
return SwingFXUtils.toFXImage(reader.read(0, reader.getDefaultReadParam()), null);
} finally {
reader.dispose();
}
public static Image loadImage(Path path) throws Exception {
return loadImage(path, 0, 0, false, false);
}
public static Image loadImage(Path path) throws Exception {
try (InputStream input = Files.newInputStream(path)) {
if ("webp".equalsIgnoreCase(FileUtils.getExtension(path)))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
public static Image loadImage(Path path,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception {
try (var input = new BufferedInputStream(Files.newInputStream(path))) {
String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT);
ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext);
if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
}
}
public static Image loadImage(String url) throws Exception {
URI uri = NetworkUtils.toURI(url);
if (DataUri.isDataUri(uri)) {
DataUri dataUri = new DataUri(uri);
if ("image/webp".equalsIgnoreCase(dataUri.getMediaType())) {
return loadWebPImage(new ByteArrayInputStream(dataUri.readBytes()));
} else {
Image image = new Image(new ByteArrayInputStream(dataUri.readBytes()));
if (image.isError())
throw image.getException();
return image;
}
}
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection) {
if (connection instanceof HttpURLConnection)
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
}
try (InputStream input = connection.getInputStream()) {
String path = uri.getPath();
if (path != null && "webp".equalsIgnoreCase(StringUtils.substringAfterLast(path, '.')))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
String contentType = Objects.requireNonNull(connection.getContentType(), "");
Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType);
if (matcher.find())
contentType = matcher.group("type");
ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType);
if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, 0, 0, false, false);
}
}
@@ -1010,45 +995,12 @@ public final class FXUtils {
}
}
public static Task<Image> getRemoteImageTask(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> {
try (var channel = FileChannel.open(file, StandardOpenOption.READ)) {
var header = new byte[12];
var buffer = ByteBuffer.wrap(header);
//noinspection StatementWithEmptyBody
while (channel.read(buffer) > 0) {
}
channel.position(0L);
if (!buffer.hasRemaining()) {
// WebP File
if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F' &&
header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P') {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(Channels.newInputStream(channel))) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
}
}
Image image = new Image(Channels.newInputStream(channel), requestedWidth, requestedHeight, preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
}
});
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
}
public static ObservableValue<Image> newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
var image = new SimpleObjectProperty<Image>();
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
.whenComplete(Schedulers.javafx(), (result, exception) -> {

View File

@@ -166,13 +166,13 @@ public final class HTMLRenderer {
String widthAttr = node.attr("width");
String heightAttr = node.attr("height");
double width = 0;
double height = 0;
int width = 0;
int height = 0;
if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) {
try {
width = Double.parseDouble(widthAttr);
height = Double.parseDouble(heightAttr);
width = (int) Double.parseDouble(widthAttr);
height = (int) Double.parseDouble(heightAttr);
} catch (NumberFormatException ignored) {
}

View File

@@ -0,0 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
/**
* @author Glavo
*/
public interface AnimationImage {
}

View File

@@ -0,0 +1,28 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import javafx.scene.image.Image;
import java.io.InputStream;
public interface ImageLoader {
Image load(InputStream input,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception;
}

View File

@@ -0,0 +1,411 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.Timeline;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import org.jackhuang.hmcl.ui.image.apng.Png;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888BitmapSequence;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
import org.jackhuang.hmcl.util.SwingFXUtils;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class ImageUtils {
// ImageLoaders
public static final ImageLoader DEFAULT = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
Image image = new Image(input,
requestedWidth, requestedHeight,
preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
};
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
};
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
if (!"true".equals(System.getProperty("hmcl.experimental.apng", "true")))
return DEFAULT.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
try {
var sequence = Png.readArgb8888BitmapSequence(input);
final int width = sequence.header.width;
final int height = sequence.header.height;
boolean doScale;
if (requestedWidth > 0 && requestedHeight > 0
&& (requestedWidth != width || requestedHeight != height)) {
doScale = true;
if (preserveRatio) {
double scaleX = (double) requestedWidth / width;
double scaleY = (double) requestedHeight / height;
double scale = Math.min(scaleX, scaleY);
requestedWidth = (int) (width * scale);
requestedHeight = (int) (height * scale);
}
} else {
doScale = false;
}
if (sequence.isAnimated()) {
try {
return toImage(sequence, doScale, requestedWidth, requestedHeight);
} catch (Throwable e) {
LOG.warning("Failed to load animated image", e);
}
}
Argb8888Bitmap defaultImage = sequence.defaultImage;
int targetWidth;
int targetHeight;
int[] pixels;
if (doScale) {
targetWidth = requestedWidth;
targetHeight = requestedHeight;
pixels = scale(defaultImage.array,
defaultImage.width, defaultImage.height,
targetWidth, targetHeight);
} else {
targetWidth = defaultImage.width;
targetHeight = defaultImage.height;
pixels = defaultImage.array;
}
WritableImage image = new WritableImage(targetWidth, targetHeight);
image.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight,
PixelFormat.getIntArgbInstance(), pixels,
0, targetWidth);
return image;
} catch (PngException e) {
throw new IOException(e);
}
};
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
"webp", WEBP,
"apng", APNG
);
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
"image/webp", WEBP,
"image/apng", APNG
);
public static final Set<String> DEFAULT_EXTS = Set.of(
"jpg", "jpeg", "bmp", "gif"
);
public static final Set<String> DEFAULT_CONTENT_TYPES = Set.of(
"image/jpeg", "image/bmp", "image/gif"
);
// ------
public static final int HEADER_BUFFER_SIZE = 1024;
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
private static final byte[] WEBP_HEADER = {'W', 'E', 'B', 'P'};
public static boolean isWebP(byte[] headerBuffer) {
return headerBuffer.length > 12
&& Arrays.equals(headerBuffer, 0, 4, RIFF_HEADER, 0, 4)
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] PNG_HEADER = {
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
};
private static final class PngChunkHeader {
private static final int IDAT_HEADER = 0x49444154;
private static final int acTL_HEADER = 0x6163544c;
private final int length;
private final int chunkType;
private PngChunkHeader(int length, int chunkType) {
this.length = length;
this.chunkType = chunkType;
}
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
if (headerBuffer.remaining() < 8)
return null;
int length = headerBuffer.getInt();
int chunkType = headerBuffer.getInt();
return new PngChunkHeader(length, chunkType);
}
}
public static boolean isApng(byte[] headerBuffer) {
if (headerBuffer.length <= 20)
return false;
if (!Arrays.equals(
headerBuffer, 0, 8,
PNG_HEADER, 0, 8))
return false;
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
PngChunkHeader header;
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
// https://wiki.mozilla.org/APNG_Specification#Structure
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
// The `acTL` structure is described below.
if (header.chunkType == PngChunkHeader.IDAT_HEADER)
break;
if (header.chunkType == PngChunkHeader.acTL_HEADER)
return true;
final int numBytes = header.length + 4;
if (buffer.remaining() > numBytes)
buffer.position(buffer.position() + numBytes);
else
break;
}
return false;
}
public static @Nullable ImageLoader guessLoader(byte[] headerBuffer) {
if (isWebP(headerBuffer))
return WEBP;
if (isApng(headerBuffer))
return APNG;
return null;
}
public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("^\\s(?<type>image/[\\w-])");
// APNG
private static int[] scale(int[] pixels,
int sourceWidth, int sourceHeight,
int targetWidth, int targetHeight) {
assert pixels.length == sourceWidth * sourceHeight;
double xScale = ((double) sourceWidth) / targetWidth;
double yScale = ((double) sourceHeight) / targetHeight;
int[] result = new int[targetWidth * targetHeight];
for (int row = 0; row < targetHeight; row++) {
for (int col = 0; col < targetWidth; col++) {
int sourceX = (int) (col * xScale);
int sourceY = (int) (row * yScale);
int color = pixels[sourceY * sourceWidth + sourceX];
result[row * targetWidth + col] = color;
}
}
return result;
}
private static Image toImage(Argb8888BitmapSequence sequence,
boolean doScale,
int targetWidth, int targetHeight) throws PngException {
final int width = sequence.header.width;
final int height = sequence.header.height;
List<Argb8888BitmapSequence.Frame> frames = sequence.getAnimationFrames();
var framePixels = new int[frames.size()][];
var durations = new int[framePixels.length];
int[] buffer = new int[Math.multiplyExact(width, height)];
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
var frame = frames.get(frameIndex);
PngFrameControl control = frame.control;
if (frameIndex == 0 && (
control.xOffset != 0 || control.yOffset != 0
|| control.width != width || control.height != height)) {
throw new PngIntegrityException("Invalid first frame: " + control);
}
if (control.xOffset < 0 || control.yOffset < 0
|| width < 0 || height < 0
|| control.xOffset + control.width > width
|| control.yOffset + control.height > height
|| control.delayNumerator < 0 || control.delayDenominator < 0
) {
throw new PngIntegrityException("Invalid frame control: " + control);
}
int[] currentFrameBuffer = buffer.clone();
if (control.blendOp == 0) {
for (int row = 0; row < control.height; row++) {
System.arraycopy(frame.bitmap.array,
row * control.width,
currentFrameBuffer,
(control.yOffset + row) * width + control.xOffset,
control.width);
}
} else if (control.blendOp == 1) {
// APNG_BLEND_OP_OVER - Alpha blending
for (int row = 0; row < control.height; row++) {
for (int col = 0; col < control.width; col++) {
int srcIndex = row * control.width + col;
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
int srcPixel = frame.bitmap.array[srcIndex];
int dstPixel = currentFrameBuffer[dstIndex];
int srcAlpha = (srcPixel >>> 24) & 0xFF;
if (srcAlpha == 0) {
continue;
} else if (srcAlpha == 255) {
currentFrameBuffer[dstIndex] = srcPixel;
} else {
int srcR = (srcPixel >>> 16) & 0xFF;
int srcG = (srcPixel >>> 8) & 0xFF;
int srcB = srcPixel & 0xFF;
int dstAlpha = (dstPixel >>> 24) & 0xFF;
int dstR = (dstPixel >>> 16) & 0xFF;
int dstG = (dstPixel >>> 8) & 0xFF;
int dstB = dstPixel & 0xFF;
int invSrcAlpha = 255 - srcAlpha;
int outAlpha = srcAlpha + (dstAlpha * invSrcAlpha + 127) / 255;
int outR, outG, outB;
if (outAlpha == 0) {
outR = outG = outB = 0;
} else {
outR = (srcR * srcAlpha + dstR * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outG = (srcG * srcAlpha + dstG * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outB = (srcB * srcAlpha + dstB * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
}
outAlpha = Math.min(outAlpha, 255);
outR = Math.min(outR, 255);
outG = Math.min(outG, 255);
outB = Math.min(outB, 255);
currentFrameBuffer[dstIndex] = (outAlpha << 24) | (outR << 16) | (outG << 8) | outB;
}
}
}
} else {
throw new PngIntegrityException("Unsupported blendOp " + control.blendOp + " at frame " + frameIndex);
}
if (doScale)
framePixels[frameIndex] = scale(currentFrameBuffer,
width, height,
targetWidth, targetHeight);
else
framePixels[frameIndex] = currentFrameBuffer;
if (control.delayNumerator == 0) {
durations[frameIndex] = 10;
} else {
int durationsMills = 1000 * control.delayNumerator;
if (control.delayDenominator == 0)
durationsMills /= 100;
else
durationsMills /= control.delayDenominator;
durations[frameIndex] = durationsMills;
}
switch (control.disposeOp) {
case 0: // APNG_DISPOST_OP_NONE
System.arraycopy(currentFrameBuffer, 0, buffer, 0, currentFrameBuffer.length);
break;
case 1: // APNG_DISPOSE_OP_BACKGROUND
for (int row = 0; row < control.height; row++) {
int fromIndex = (control.yOffset + row) * width + control.xOffset;
Arrays.fill(buffer, fromIndex, fromIndex + control.width, 0);
}
break;
case 2: // APNG_DISPOSE_OP_PREVIOUS
// Do nothing, keep the previous frame.
break;
default:
throw new PngIntegrityException("Unsupported disposeOp " + control.disposeOp + " at frame " + frameIndex);
}
}
PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays;
if (cycleCount == 0)
cycleCount = Timeline.INDEFINITE;
} else {
cycleCount = Timeline.INDEFINITE;
}
if (doScale)
return new AnimationImageImpl(targetWidth, targetHeight, framePixels, durations, cycleCount);
else
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
}
private ImageUtils() {
}
}

View File

@@ -0,0 +1,57 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.argb8888.*;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngMap;
import org.jackhuang.hmcl.ui.image.apng.map.PngMapReader;
import org.jackhuang.hmcl.ui.image.apng.reader.DefaultPngChunkReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainer;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainerBuilder;
import java.io.InputStream;
/**
* Convenient one liners for loading PNG images.
*/
public class Png {
/**
* Read the provided stream and produce a PngMap of the data.
*
* @param is stream to read from
* @param sourceName optional name, mainly for debugging.
* @return PngMap of the data.
* @throws PngException
*/
public static PngMap readMap(InputStream is, String sourceName) throws PngException {
return PngReadHelper.read(is, new PngMapReader(sourceName));
}
public static PngContainer readContainer(InputStream is) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(new PngContainerBuilder()));
}
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
return PngReadHelper.read(is, reader);
}
public static <ResultT> ResultT read(InputStream is, PngChunkProcessor<ResultT> processor) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException {
Argb8888Processor<Argb8888Bitmap> processor = new Argb8888Processor<>(new DefaultImageArgb8888Director());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException {
Argb8888Processor<Argb8888BitmapSequence> processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
}

View File

@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* A PNG file has an animation type - most commonly, not animated.
* <p>
* This is primarily relevant to tracking whether the default image in a PNG
* is to be
* a) processed as normal (e.g. in a standard or "non-animated" PNG file);
* b) processed and used as the first frame in an animated PNG; or
* c) not processed and discarded (e.g. in an animated PNG which does not use the first frame).
*/
public enum PngAnimationType {
NOT_ANIMATED,
ANIMATED_KEEP_DEFAULT_IMAGE,
ANIMATED_DISCARD_DEFAULT_IMAGE;
}

View File

@@ -0,0 +1,123 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* One four-letter (32-byte) code identifying the type of a PNG chunk.
* <p>
* Common chunk codes are defined as constants that can be used in a switch statement.
* Users can add their own chunk codes separately.
*/
public class PngChunkCode {
public static final PngChunkCode IHDR = new PngChunkCode(PngConstants.IHDR_VALUE, "IHDR");
public static final PngChunkCode PLTE = new PngChunkCode(PngConstants.PLTE_VALUE, "PLTE");
public static final PngChunkCode IDAT = new PngChunkCode(PngConstants.IDAT_VALUE, "IDAT");
public static final PngChunkCode IEND = new PngChunkCode(PngConstants.IEND_VALUE, "IEND");
public static final PngChunkCode gAMA = new PngChunkCode(PngConstants.gAMA_VALUE, "gAMA");
public static final PngChunkCode bKGD = new PngChunkCode(PngConstants.bKGD_VALUE, "bKGD");
public static final PngChunkCode tRNS = new PngChunkCode(PngConstants.tRNS_VALUE, "tRNS");
public static final PngChunkCode acTL = new PngChunkCode(PngConstants.acTL_VALUE, "acTL");
public static final PngChunkCode fcTL = new PngChunkCode(PngConstants.fcTL_VALUE, "fcTL");
public static final PngChunkCode fdAT = new PngChunkCode(PngConstants.fdAT_VALUE, "fdAT");
public final int numeric;
public final String letters;
PngChunkCode(int numeric, String letters) {
this.numeric = numeric;
this.letters = letters;
}
/**
* Find out if the chunk is "critical" as defined by http://www.w3.org/TR/PNG/#5Chunk-naming-conventions
*
* <pre>
* cHNk <-- 32 bit chunk type represented in text form
* ||||
* |||+- Safe-to-copy bit is 1 (lower case letter; bit 5 is 1)
* ||+-- Reserved bit is 0 (upper case letter; bit 5 is 0)
* |+--- Private bit is 0 (upper case letter; bit 5 is 0)
* +---- Ancillary bit is 1 (lower case letter; bit 5 is 1)
* </pre>
*
* @return true if this chunk is considered critical according to the PNG spec.
*/
public boolean isCritical() {
// Ancillary bit: bit 5 o first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) == 0;
}
public boolean isAncillary() {
// Ancillary bit: bit 5 of first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) > 0;
}
public boolean isPrivate() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) > 0;
}
public boolean isPublic() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) == 0;
}
public boolean isReserved() {
return (numeric & PngConstants.BIT_CHUNK_IS_RESERVED) > 0;
}
public boolean isSafeToCopy() {
return (numeric & PngConstants.BIT_CHUNK_IS_SAFE_TO_COPY) > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngChunkCode that = (PngChunkCode) o;
if (numeric != that.numeric) return false;
return !(letters != null ? !letters.equals(that.letters) : that.letters != null);
}
@Override
public int hashCode() {
int result = numeric;
result = 31 * result + (letters != null ? letters.hashCode() : 0);
return result;
}
@Override
public String toString() {
return letters + "(" + numeric + ")";
}
public static PngChunkCode from(int code) {
switch (code) {
case PngConstants.IHDR_VALUE:
return IHDR;
case PngConstants.gAMA_VALUE:
return gAMA;
case PngConstants.bKGD_VALUE:
return bKGD;
case PngConstants.tRNS_VALUE:
return tRNS;
case PngConstants.IDAT_VALUE:
return IDAT;
case PngConstants.IEND_VALUE:
return IEND;
case PngConstants.acTL_VALUE:
return acTL;
case PngConstants.fdAT_VALUE:
return fdAT;
default:
byte[] s = new byte[4];
s[0] = (byte) ((code & 0xff000000) >> 24);
s[1] = (byte) ((code & 0x00ff0000) >> 16);
s[2] = (byte) ((code & 0x0000ff00) >> 8);
s[3] = (byte) ((code & 0x000000ff));
return new PngChunkCode(code, new String(s));
}
}
}

View File

@@ -0,0 +1,61 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* PNG images support 5 colour types as defined at http://www.w3.org/TR/PNG/#11IHDR
*/
public enum PngColourType {
PNG_GREYSCALE(0, 1, "1, 2, 4, 8, 16", "Greyscale", "Each pixel is a greyscale sample"),
PNG_TRUECOLOUR(2, 3, "8, 16", "Truecolour", "Each pixel is an R,G,B triple"),
PNG_INDEXED_COLOUR(3, 1, "1, 2, 4, 8", "Indexed-colour", "Each pixel is a palette index; a PLTE chunk shall appear."),
PNG_GREYSCALE_WITH_ALPHA(4, 2, "4, 8, 16", "Greyscale with alpha", "Each pixel is a greyscale sample followed by an alpha sample."),
PNG_TRUECOLOUR_WITH_ALPHA(6, 4, "8, 16", "Truecolour with alpha", "Each pixel is an R,G,B triple followed by an alpha sample.");
public final int code;
public final int componentsPerPixel;
public final String allowedBitDepths;
public final String name;
public final String descriptino;
PngColourType(int code, int componentsPerPixel, String allowedBitDepths, String name, String descriptino) {
this.code = code;
this.componentsPerPixel = componentsPerPixel;
this.allowedBitDepths = allowedBitDepths;
this.name = name;
this.descriptino = descriptino;
}
public boolean isIndexed() {
return (code & 0x01) > 0;
}
public boolean hasAlpha() {
return (code & 0x04) > 0;
}
public boolean supportsSubByteDepth() {
return code == 0 || code == 3;
}
public static PngColourType fromByte(byte b) throws PngException {
switch (b) {
case 0:
return PNG_GREYSCALE;
case 2:
return PNG_TRUECOLOUR;
case 3:
return PNG_INDEXED_COLOUR;
case 4:
return PNG_GREYSCALE_WITH_ALPHA;
case 6:
return PNG_TRUECOLOUR_WITH_ALPHA;
default:
throw new PngIntegrityException(String.format("Valid PNG colour types are 0, 2, 3, 4, 6. Type '%d' is invalid", b));
}
}
}

View File

@@ -0,0 +1,120 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
public class PngConstants {
public static final int LENGTH_SIGNATURE = 8;
public static final int LENGTH_fcTL_CHUNK = 4 + 4 + 4 + 4 + 4 + 2 + 2 + 1 + 1;
public static final int LENGTH_acTL_CHUNK = 4 + 4;
public static final byte[] BYTES_SIGNATURE = new byte[]{
(byte) 0x89, 'P', 'N', 'G', (byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A
};
public static final int IHDR_VALUE = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R'; // 1229472850;
public static final int PLTE_VALUE = 'P' << 24 | 'L' << 16 | 'T' << 8 | 'E';
public static final int IDAT_VALUE = 'I' << 24 | 'D' << 16 | 'A' << 8 | 'T'; // 1229209940;
public static final int IEND_VALUE = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D'; // 1229278788;
public static final int gAMA_VALUE = 'g' << 24 | 'A' << 16 | 'M' << 8 | 'A'; // 1732332865;
public static final int bKGD_VALUE = 'b' << 24 | 'K' << 16 | 'G' << 8 | 'D'; // 1649100612;
public static final int tRNS_VALUE = 't' << 24 | 'R' << 16 | 'N' << 8 | 'S';
public static final int acTL_VALUE = 'a' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fcTL_VALUE = 'f' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fdAT_VALUE = 'f' << 24 | 'd' << 16 | 'A' << 8 | 'T';
public static final int ERROR_NOT_PNG = 1;
public static final int ERROR_EOF = 2;
public static final int ERROR_EOF_EXPECTED = 3;
public static final int ERROR_UNKNOWN_IO_FAILURE = 4;
public static final int ERROR_FEATURE_NOT_SUPPORTED = 5;
public static final int ERROR_INTEGRITY = 6;
public static final int ONE_K = 1 << 10;
public static final int DEFAULT_TARGET_BUFFER_SIZE = 32 * ONE_K;
public static final byte[] GREY_PALETTE_16 = new byte[]{
(byte) 0x00, // 0
(byte) 0x11, // 1
(byte) 0x22, // 2
(byte) 0x33, // 3
(byte) 0x44, // 4
(byte) 0x55, // 5
(byte) 0x66, // 6
(byte) 0x77, // 7
(byte) 0x88, // 8
(byte) 0x99, // 9
(byte) 0xaa, // 10
(byte) 0xbb, // 11
(byte) 0xcc, // 12
(byte) 0xdd, // 13
(byte) 0xee, // 14
(byte) 0xff, // 15
};
// public static final byte[] MASKS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//
// public static final byte[] MASKS_2 = new byte[] {
// (byte)0x03, // bit 0-1
// (byte)0x0C, // bit 2-3
// (byte)0x30, // bit 4-5
// (byte)0xC0, // bit 6-7
// };
//
// public static final byte[] MASKS_4 = new byte[] {
// (byte)0x0F, // bit 0-3
// (byte)0xF0, // bit 4-7
// };
public static final byte[] SHIFTS_1 = new byte[]{
(byte) 0, // bit 0
(byte) 1, // bit 1
(byte) 2, // bit 2
(byte) 3, // bit 3
(byte) 4, // bit 4
(byte) 5, // bit 5
(byte) 6, // bit 6
(byte) 7, // bit 7
};
public static final byte[] SHIFTS_2 = new byte[]{
(byte) 0, // bit 0-1
(byte) 2, // bit 2-3
(byte) 4, // bit 4-5
(byte) 6 // bit 6-7
};
public static final byte[] SHIFTS_4 = new byte[]{
(byte) 0x00, // bit 0-3
(byte) 0x04, // bit 4-7
};
public static final int BIT_CHUNK_IS_ANCILLARY = 0x20000000;
public static final int BIT_CHUNK_IS_PRIVATE = 0x00200000;
public static final int BIT_CHUNK_IS_RESERVED = 0x00002000;
public static final int BIT_CHUNK_IS_SAFE_TO_COPY = 0x00000020;
// public static final byte[] SHIFTS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//public static final PngChunkCodes Foo = new PngChunkCodes(123, "Foo");
}

View File

@@ -0,0 +1,125 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* Each "undo*" function reverses the effect of a filter on a given single scanline.
*/
public class PngFilter {
public static void undoSubFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// int ai = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
//
// final int x = bytes[i];
// final int a = bytes[pixelStart + ai];
int ai = pixelStart;
for (int i = pixelStart + filterUnit; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[ai];
//bytes[rowPosition] = (byte)((bytes[rowPosition] + left) & 0xff); // TODO & 0xff
bytes[i] = (byte) ((x + a) & 0xff);
ai++;
}
}
public static void undoUpFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// for (int i=1; i<bytesPerLine; i++) {
// rowPosition++; // before the first op to skip filter byte
// bytes[rowPosition] = (byte)((bytes[rowPosition] + previousRow[i]) & 0xff);
// }
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte) ((x + b) & 0xff);
bi++;
}
}
public static void undoAverageFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
int ai = pixelStart - filterUnit;
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = (ai < pixelStart) ? 0 : (0xff & bytes[ai]);
final int b = (0xff & previousRow[bi]);
// int ai = pixelStart;
// int bi = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
// final int x = bytes[i];
// final int a = bytes[ai];
// final int b = previousRow[bi];
//bytes[i] = (byte)((x+((a+b)>>1))&0xff);
//int z = x+(a+b)/2;
int z = x + ((a + b) / 2);
bytes[i] = (byte) (0xff & z);
ai++;
bi++;
}
/*
int bi = 0;
for (int i=pixelStart; i<pixelStart+filterUnit; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte)((x + (b >> 1)) & 0xff);
bi++;
}
for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[pixelStart+bi-filterUnit];
final int b = previousRow[bi];
bytes[i] = (byte)((x + ((a+b) >> 1)) & 0xff);
bi++;
}*/
}
/**
* See http://www.libpng.org/pub/png/spec/1.2/PNG-Filters.html
*
*/
public static void undoPaethFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
//int scratch;
//int previousLeft = 0;
//int left = 0;
int ai = pixelStart - filterUnit;
int bi = 0;
int ci = -filterUnit;
//for (int i=0; i<bytesPerLine-1; i++) {
//for (int i=filterUnit; i<bytesPerLine-1; i++) {
for (int i = pixelStart; i < pixelEnd; i++) {
final int a, b, c, x;
x = bytes[i];
if (ai < pixelStart) {
a = c = 0;
} else {
a = 0xff & bytes[ai];
c = 0xff & previousRow[ci];
}
b = 0xff & previousRow[bi];
final int p = a + b - c;
final int pa = p >= a ? p - a : a - p;
final int pb = p >= b ? p - b : b - p;
final int pc = p >= c ? p - c : c - p;
final int predicted = (pa <= pb && pa <= pc) ? a
: (pb <= pc) ? b
: c;
bytes[i] = (byte) ((x + predicted) & 0xff);
ai++;
bi++;
ci++;
}
}
}

View File

@@ -0,0 +1,166 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngScanlineProcessor;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/**
* A PngScanlineBuffer allows up-front once-off memory allocation that can be used
* across an entire PNG (no matter how many frames) to accumulate and process image data.
*/
public class PngScanlineBuffer {
protected final byte[] bytes;
protected final int filterUnit;
protected byte[] previousLine;
protected int readPosition;
protected int writePosition;
//protected int numBytesPerLine;
public PngScanlineBuffer(int size, int maxBytesPerLine, int filterUnit) {
this.bytes = new byte[size];
this.previousLine = new byte[maxBytesPerLine];
//this.scratchLine = new byte[maxBytesPerLine];
//this.numBytesPerLine = numBytesPerLine;
this.filterUnit = filterUnit;
reset();
}
public byte[] getBytes() {
return bytes;
}
public int getReadPosition() {
return readPosition;
}
public int getWritePosition() {
return writePosition;
}
public boolean canRead(int minNumBytes) {
return availableRead() >= minNumBytes;
}
public int availableRead() {
return writePosition - readPosition;
}
public int availableWrite() {
return bytes.length - writePosition;
}
public void skip(int numBytes) {
if (numBytes < 0 || numBytes > availableRead()) {
throw new IllegalArgumentException(String.format("Skip bytes must be 0 > n >= %d: %d", availableRead(), numBytes));
}
readPosition += numBytes;
}
public void reset() {
this.readPosition = 0;
this.writePosition = 0;
Arrays.fill(previousLine, (byte) 0);
}
public void shift() {
// It is difficult to say whether there is a more efficient approach - e.g. a for loop -
// to shifting the bytes to the start of the array. For now, I consider System.arraycopy
// to be "good enough".
int len = availableRead();
System.arraycopy(bytes, readPosition, bytes, 0, len);
writePosition = len;
readPosition = 0;
}
/**
* Read the decompressed data into the buffer.
*
* @param is an InputStream yielding decompressed data.
* @return count of how many bytes read, or -1 if EOF hit.
* @throws IOException
*/
public int read(InputStream is) throws IOException {
int r = is.read(bytes, writePosition, availableWrite());
if (r > 0) {
writePosition += r;
}
return r;
}
public boolean decompress(InputStream inputStream, PngScanlineProcessor processor) throws IOException, PngException {
int bytesPerLine = processor.getBytesPerLine();
try (InputStream iis = processor.makeInflaterInputStream(inputStream)) {
while (true) {
// Ally says
// I LOVE Y.O.U.
int len = read(iis);
if (len <= 0) {
return processor.isFinished();
}
// Or could do: len -= bytesPerLine until len < bytesPerLine
while (canRead(bytesPerLine)) {
undoFilterScanline(bytesPerLine);
processor.processScanline(bytes, readPosition + 1);//(), getReadPosition());
skip(bytesPerLine);
}
shift();
}
}
}
public void undoFilterScanline(int bytesPerLine) {
int filterCode = bytes[readPosition];
switch (filterCode) {
case 0:
// NOP
break;
case 1:
PngFilter.undoSubFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 2:
PngFilter.undoUpFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 3:
PngFilter.undoAverageFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 4:
PngFilter.undoPaethFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
default: // I toyed with an exception here. But why? Just treat as if no filter.
//throw new IllegalArgumentException(String.format("Filter type %d invalid; valid is 0..4", filterCode);
break;
}
// when un-filtering scanlines, the previous row is a copy of the *un-filtered* row (not the original).
System.arraycopy(bytes, readPosition + 1, previousLine, 0, bytesPerLine - 1); // keep a copy of the unmodified row
}
public static PngScanlineBuffer from(PngHeader header) {
return from(header, PngConstants.DEFAULT_TARGET_BUFFER_SIZE);
}
public static PngScanlineBuffer from(PngHeader header, int minBufferSize) {
return new PngScanlineBuffer(sizeFrom(header, minBufferSize), header.bytesPerRow, header.filterOffset);
}
public static int sizeFrom(PngHeader header, int minBufferSize) {
int bytesPerRow = header.bytesPerRow;
int bytesFullBitmap = bytesPerRow * header.height;
if (bytesFullBitmap < minBufferSize) {
return bytesFullBitmap;
}
int numRows = Math.max(1, minBufferSize / bytesPerRow);
return numRows * bytesPerRow;
}
}

View File

@@ -0,0 +1,48 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
/**
* A bitmap where each pixel is represented by 8-bits for alpha, red, green and blue
* respectively, and each pixel stored in a single 32-bit integer. This is expressly designed
* to be compatible with the input array used to build Android Bitmap objects, though of course
* its use is not limited to that.
*/
public final class Argb8888Bitmap {
public final int[] array;
public final int width;
public final int height;
public Argb8888Bitmap(int width, int height) {
this(new int[width * height], width, height);
}
public Argb8888Bitmap(int[] array, int width, int height) {//}, int x, int y) {
this.array = array;
this.width = width;
this.height = height;
// this.x = y;
}
/**
* Create a new Bitmap that shares the byte array of this bitmap.
* This is useful when rendering a number of animation frames from an APNG file
* and wanting to minimise memory allocation, while still wanting a "new" bitmap
* to work on.
*
* @param width in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @param height in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @return new bitmap object sharing the same data array.
*/
public Argb8888Bitmap makeView(int width, int height) {//}, int x, int y) {
if ((width * height) > (this.width * this.height)) {
throw new IllegalArgumentException(String.format(
"Requested width and height (%d x %d) exceeds maximum pixels allowed by host bitmap (%d x %d",
width, height, this.width, this.height));
}
return new Argb8888Bitmap(array, width, height);
}
}

View File

@@ -0,0 +1,72 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import java.util.ArrayList;
import java.util.List;
/**
* An Argb8888BitmapSequence object represents all bitmaps in a single PNG file,
* whether it has one and only one default image, any number of frames in an animation
* or a default image and a separate set of animation frames.
* <p>
* Note that instances of this class will hold an individual bitmap for every frame
* and does <em>not</em> do composition of images in any way. Composition is done in
* the japng_android library using an Android Canvas. This class is not used for that,
* as the intermediate Argb8888Bitmap objects are not required, only one bitmap and
* the output buffer is required during composition.
*/
public final class Argb8888BitmapSequence {
public final PngHeader header;
public final Argb8888Bitmap defaultImage;
private boolean defaultImageIsSet = false;
private PngAnimationControl animationControl;
List<Frame> animationFrames;
public Argb8888BitmapSequence(PngHeader header) {
this.header = header;
this.defaultImage = new Argb8888Bitmap(header.width, header.height);
}
public void receiveAnimationControl(PngAnimationControl animationControl) {
this.animationControl = animationControl;
this.animationFrames = new ArrayList<>(animationControl.numFrames);
}
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
defaultImageIsSet = true;
}
public boolean hasDefaultImage() {
return defaultImageIsSet;
}
public boolean isAnimated() {
return null != animationControl && animationControl.numFrames > 0;
}
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<Frame> getAnimationFrames() {
return animationFrames;
}
public static final class Frame {
public final PngFrameControl control;
public final Argb8888Bitmap bitmap;
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
this.control = control;
this.bitmap = bitmap;
}
}
}

View File

@@ -0,0 +1,82 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888BitmapSequenceDirector instances direct an Argb8888Processor to build all frames
* of an animation into an Argb8888BitmapSequence object.
*/
public class Argb8888BitmapSequenceDirector extends BasicArgb8888Director<Argb8888BitmapSequence> {
Argb8888BitmapSequence bitmapSequence = null;
PngFrameControl currentFrame = null;
private PngHeader header;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
this.header = header;
this.bitmapSequence = new Argb8888BitmapSequence(header);
//this.header = header;
//defaultImage = new Argb8888Bitmap(header.width, header.height);
this.scanlineProcessor = Argb8888Processors.from(header, buffer, this.bitmapSequence.defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return true;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
this.bitmapSequence.receiveDefaultImage(bitmap);
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
this.bitmapSequence.receiveAnimationControl(control);
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
//throw new IllegalStateException("TODO up to here");
//return null;
currentFrame = control;
//System.out.println("Frame: "+control);
return scanlineProcessor.cloneWithNewBitmap(header.adjustFor(control)); // TODO: is this going to be a problem?
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
if (null == currentFrame) {
throw new IllegalStateException("Received a frame image with no frame control in place");
}
if (null == bitmapSequence.animationFrames) {
throw new IllegalStateException("Received a frame image without animation control (or frame list?) in place");
}
bitmapSequence.animationFrames.add(new Argb8888BitmapSequence.Frame(currentFrame, bitmap));
currentFrame = null;
}
@Override
public Argb8888BitmapSequence getResult() {
return bitmapSequence;
}
}

View File

@@ -0,0 +1,47 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888Director implementations "direct" a given Argb8888Processor how to
* control the output. This allows the Argb8888Processor to transform pixels into
* ARGB8888 format while allowing for radically different final output objects,
* e.g. a single bitmap, a sequence of bitamps, an Android View or Drawable, etc.
* <p>
* TODO: not sure if this will stay in this form. Needs refinement.
*/
public interface Argb8888Director<ResultT> {
void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException;
void receivePalette(Argb8888Palette palette);
void processTransparentPalette(byte[] bytes, int position, int length) throws PngException;
void processTransparentGreyscale(byte k1, byte k0) throws PngException;
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException;
boolean wantDefaultImage();
boolean wantAnimationFrames();
Argb8888ScanlineProcessor beforeDefaultImage();
void receiveDefaultImage(Argb8888Bitmap bitmap);
void receiveAnimationControl(PngAnimationControl control);
Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control);
void receiveFrameImage(Argb8888Bitmap bitmap);
ResultT getResult();
}

View File

@@ -0,0 +1,117 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A palette implementation representing colours in ARGB8888 format as 32-bit integers.
* <p>
* Note that the transparency of individual entries in the palette is modified in-place
* when the trNS chunk is processed.
*
* @see BasicArgb8888Director
*/
public class Argb8888Palette {
protected static Argb8888Palette monochromePalette = null;
protected static Argb8888Palette greyPalette2 = null;
protected static Argb8888Palette greyPalette4 = null;
protected static Argb8888Palette greyPalette8 = null;
/**
* The colour array is public and mutable by design.
*
* @see this.get()
*/
public final int[] argbArray;
/**
* Construct a new palette using the specific array passed as the argument as-is (not copied).
*
* @param argbArray array of colours to use as-is (it is not copied).
*/
public Argb8888Palette(int[] argbArray) {
this.argbArray = argbArray;
}
/**
* Retrieve a colour using a method. Users can also just use the argbArray directly.
*
* @param index of colour to retrieve
* @return integer representing the colour at that index.
*/
public int get(int index) {
return this.argbArray[index];
}
/**
* @return the number of colours in this palette, which is the same thing as the size of the array.
*/
public int size() {
return argbArray.length;
}
/**
* Build a new palette by reading the byte array (from the offset position and for length bytes)
* as provided in a PLTE chunk.
*
* @param bytes array to read data from
* @param position offset into bytes array to begin reading from
* @param length number of bytes to read from bytes array
* @return new palette formed by reading the bytes array.
* @throws PngException
*/
public static Argb8888Palette fromPaletteBytes(byte[] bytes, int position, int length) throws PngException {
int numColours = length / 3; // guaranteed to be divisible by 3
int[] argbArray = new int[numColours];
int srcIndex = position;
int alpha = 0xff << 24;
for (int destIndex = 0; destIndex < numColours; destIndex++) {
final int r = bytes[srcIndex++] & 0xff;
final int g = bytes[srcIndex++] & 0xff;
final int b = bytes[srcIndex++] & 0xff;
argbArray[destIndex] = alpha | r << 16 | g << 8 | b;
}
return new Argb8888Palette(argbArray);
}
public static Argb8888Palette forGreyscale(int numEntries, int step) {
int[] array = new int[numEntries];
int alpha = 0xff << 24;
int grey = 0;
for (int i = 0; i < numEntries; i++) {
array[i] = alpha | grey << 16 | grey << 8 | grey;
grey = (grey + step) & 0xff;
}
return new Argb8888Palette(array);
}
public static Argb8888Palette forGreyscale(int bitDepth) throws PngException {
switch (bitDepth) {
case 1:
if (null == monochromePalette) {
monochromePalette = forGreyscale(2, 0xff); // Worth it or not really?
}
return monochromePalette;
case 2:
if (null == greyPalette2) {
greyPalette2 = forGreyscale(4, 0x55);
}
return greyPalette2;
case 4:
if (null == greyPalette4) {
greyPalette4 = forGreyscale(16, 0x11);
}
return greyPalette4;
case 8: // TODO: need??
if (null == greyPalette8) {
greyPalette8 = forGreyscale(256, 0x01);
}
return greyPalette8;
default:
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
}
}
}

View File

@@ -0,0 +1,173 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import java.io.IOException;
import java.io.InputStream;
/**
* Concrete implementation of a chunk processor designed to process pixels into
* 32-bit ARGB8888 format.
*/
public class Argb8888Processor<ResultT> implements PngChunkProcessor<ResultT> { //PngChunkProcessor<Argb8888Bitmap> {
protected PngHeader header = null;
protected PngScanlineBuffer scanlineReader = null;
protected Argb8888Director<ResultT> builder = null;
protected Argb8888ScanlineProcessor scanlineProcessor = null;
public Argb8888Processor(Argb8888Director<ResultT> builder) {
this.builder = builder;
}
@Override
public void processHeader(PngHeader header) throws PngException {
// super.processHeader(header);
//// if (header.bitDepth != 1 || header.colourType != PngColourType.PNG_GREYSCALE) {
//// throw new PngFeatureException("ARGB888 only supports 1-bit greyscale");
//// }
// if (header.isGreyscale()) {
// palette = Argb8888Palette.forGreyscale(header.bitDepth);
// }
// scanlineReader = PngScanlineBuffer.from(header);
this.header = header;
this.scanlineReader = PngScanlineBuffer.from(header);
this.builder.receiveHeader(this.header, this.scanlineReader);
}
@Override
public void processGamma(PngGamma gamma) throws PngException {
// No gamma processing is done at the moment.
}
@Override
public void processPalette(byte[] bytes, int position, int length) throws PngException {
//palette = Argb8888Palette.fromPaletteBytes(bytes, position, length);
//scanlineProcessor = Argb8888Processors.fromPalette(this.header, palette);
builder.receivePalette(Argb8888Palette.fromPaletteBytes(bytes, position, length));
}
@Override
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
switch (header.colourType) {
case PNG_GREYSCALE: // colour type 0
// grey sample value (2 bytes)
if (length != 2) {
throw new PngIntegrityException(String.format("tRNS chunk for greyscale image must be exactly length=2, not %d", length));
}
builder.processTransparentGreyscale(bytes[0], bytes[1]);
break;
case PNG_TRUECOLOUR: // colour type 2
// red, green, blue samples, EACH with two bytes (16-bits)
builder.processTransparentTruecolour(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]);
break;
case PNG_INDEXED_COLOUR: // colour type 3
// This is a sequence of one-byte alpha values to apply to each palette entry starting at zero.
// The number of entries may be less than the size of the palette, but not more.
builder.processTransparentPalette(bytes, position, length);
break;
case PNG_GREYSCALE_WITH_ALPHA:
case PNG_TRUECOLOUR_WITH_ALPHA:
default:
throw new PngIntegrityException("Illegal to have tRNS chunk with image type " + header.colourType.name);
}
}
/**
* The only supported animation type is not "NOT_ANIMATED".
*/
// @Override
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
// scanlineProcessor = Argb8888ScanlineProcessor.from(header, scanlineReader, currentFrame);
// return PngAnimationType.NOT_ANIMATED;
// }
@Override
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (!builder.wantDefaultImage()) {
inputStream.skip(length); // important!
return;
}
if (null == scanlineProcessor) { // TODO: is that a good enough metric? Or could be numIdat==0?
scanlineProcessor = builder.beforeDefaultImage();
if (null == scanlineProcessor) throw new IllegalStateException("Builder must create scanline processor");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveDefaultImage(scanlineProcessor.getBitmap());
scanlineProcessor = null;
scanlineReader.reset();
}
}
@Override
public void processAnimationControl(PngAnimationControl animationControl) throws PngException {
if (builder.wantAnimationFrames()) {
builder.receiveAnimationControl(animationControl);
}
}
@Override
public void processFrameControl(PngFrameControl frameControl) throws PngException {
if (!builder.wantAnimationFrames()) {
return;
}
if (null == scanlineProcessor) {
// If here, the data in this frame image has not started
scanlineProcessor = builder.receiveFrameControl(frameControl);
if (null == scanlineProcessor)
throw new IllegalStateException("Builder must create scanline processor for frame");
} else {
throw new IllegalStateException("received animation frame control but image data was in progress");
}
}
@Override
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
//throw new PngFeatureException("PngArgb8888Processor does not support animation frames");
if (!builder.wantAnimationFrames()) {
inputStream.skip(length);
return;
}
if (null == scanlineProcessor) {
throw new IllegalStateException("received animation frame image data before frame control or without processor in place");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveFrameImage(scanlineProcessor.getBitmap());
scanlineReader.reset();
scanlineProcessor = null;
}
}
@Override
public void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException {
// NOP
}
@Override
public ResultT getResult() {
//return scanlineProcessor.getBitmap();
return builder.getResult();
}
}

View File

@@ -0,0 +1,462 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A series of scanline processor implementations for different input pixel formats,
* each able to transform the input into ARGB8888 output pixels.
*/
public class Argb8888Processors {
/**
* Determine from the header the concrete Argb8888Processor implementation the
* caller needs to transform the incoming pixels into the ARGB8888 pixel format.
*
* @param header of the image being loaded
* @param scanlineReader the scanline buffer being used
* @param bitmap the destination bitmap
* @return a concrete Argb8888Processor to transform source pixels into the specific bitmap.
* @throws PngException if there is a feature not supported or some specification break with the file.
*/
public static Argb8888ScanlineProcessor from(PngHeader header, PngScanlineBuffer scanlineReader, Argb8888Bitmap bitmap) throws PngException {
int bytesPerScanline = header.bytesPerRow;
switch (header.colourType) {
case PNG_GREYSCALE:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1, Argb8888Palette.forGreyscale(1));
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2, Argb8888Palette.forGreyscale(2));
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4, Argb8888Palette.forGreyscale(4));
case 8:
return new Greyscale8(bytesPerScanline, bitmap);
case 16:
throw new PngFeatureException("Greyscale supports 1, 2, 4, 8 but not 16.");
default:
throw new PngIntegrityException(String.format("Invalid greyscale bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_GREYSCALE_WITH_ALPHA:
switch (header.bitDepth) {
case 4:
return new Greyscale4Alpha(bytesPerScanline, bitmap);
case 8:
return new Greyscale8Alpha(bytesPerScanline, bitmap);
case 16:
return new Greyscale16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid greyscale-with-alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_INDEXED_COLOUR:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1);
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2);
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4);
case 8:
return new IndexedColour8(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid indexed colour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR:
switch (header.bitDepth) {
case 8:
return new Truecolour8(bytesPerScanline, bitmap);
case 16:
return new Truecolour16(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR_WITH_ALPHA:
switch (header.bitDepth) {
case 8:
return new Truecolour8Alpha(bytesPerScanline, bitmap);
case 16:
return new Truecolour16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour with alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
default:
throw new PngFeatureException("ARGB8888 doesn't support PNG mode " + header.colourType.name());
}
}
/**
* Transforms 1-, 2-, 4-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColourBits extends Argb8888ScanlineProcessor {
private int highBit;
private int mask;
private byte[] shifts;
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts) {
this(bytesPerScanline, bitmap, highBit, mask, shifts, null);
}
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts, Argb8888Palette palette) {
super(bytesPerScanline, bitmap);
this.highBit = highBit;
this.mask = mask;
this.shifts = shifts;
this.palette = palette;
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
int lastWritePosition = writePosition + width;
int bit = highBit;
while (writePosition < lastWritePosition) {
//final int index=(srcBytes[srcPosition+x] & masks[bit]) >> shifts[bit];
//final int index = (srcBytes[srcPosition])
//final int v = srcBytes[srcPosition];
final int index = mask & (srcBytes[srcPosition] >> shifts[bit]);
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
if (bit == 0) {
srcPosition++;
bit = highBit;
} else {
bit--;
}
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColourBits(bytesPerRow, bitmap, highBit, mask, shifts, palette);
}
}
/**
* Special case implementation to transform 8-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColour8 extends Argb8888ScanlineProcessor {
public IndexedColour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int index = 0xff & srcBytes[srcPosition++]; // TODO: need to use transparency and background chunks
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColour8(bytesPerRow, bitmap);
}
}
/**
* Transforms 4-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale4Alpha extends Argb8888ScanlineProcessor {
public Greyscale4Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int v = srcBytes[srcPosition++];
final int k = PngConstants.GREY_PALETTE_16[0x0f & (v >> 4)];
final int a = PngConstants.GREY_PALETTE_16[0x0f & v];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale4Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale source pixels to ARGB8888 pixels.
*/
public static class Greyscale8 extends Argb8888ScanlineProcessor {
boolean haveTransparent = false;
int transparentSample = 0;
public Greyscale8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int sample = 0xff & srcBytes[srcPosition++];
final int k = (haveTransparent && sample == transparentSample) ? transparentSample : sample;
final int c = alpha | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) {
// According to below, when image is less than 16-bits per pixel, use least significant byte
// http://www.w3.org/TR/PNG/#11transinfo
haveTransparent = true;
transparentSample = 0xff & k0; // NOT k1 according to http://www.w3.org/TR/PNG/#11transinfo
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale8Alpha extends Argb8888ScanlineProcessor {
public Greyscale8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 16-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale16Alpha extends Argb8888ScanlineProcessor {
public Greyscale16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the grey
final int a = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the alpha
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale16Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8 extends Argb8888ScanlineProcessor {
public Truecolour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8Alpha extends Argb8888ScanlineProcessor {
public Truecolour8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 16-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour16 extends Argb8888ScanlineProcessor {
public Truecolour16(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 16-bit source pixels to ARGB8888 pixels.
* <p>
* Note that the simpler method of resampling the colour is done, namely discard the LSB.
*/
public static class Truecolour16Alpha extends Argb8888ScanlineProcessor {
public Truecolour16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
//final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int alpha = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int c = alpha << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16(bytesPerRow, bitmap);
}
}
private Argb8888Processors() {
}
}

View File

@@ -0,0 +1,84 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.BasicScanlineProcessor;
/**
* Base implementation that transforms scanlines of source pixels into scanlines of
* ARGB8888 (32-bit integer) pixels in a destination Argb8888Bitmap object.
* <p>
* Note: I wonder if a better name is ScanlineConverter or PixelTransformer.
*
* @see Argb8888Processors
*/
public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
protected Argb8888Bitmap bitmap;
protected Argb8888Palette palette;
protected int y = 0;
// public Argb8888ScanlineProcessor(PngHeader header) {
// this(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
// }
public Argb8888ScanlineProcessor(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline);
this.bitmap = bitmap;
}
// public Argb8888ScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
// super(header, scanlineReader, currentFrame);
// bitmap = new Argb8888Bitmap(this.header.width, this.header.height);
// }
public Argb8888Bitmap getBitmap() {
return bitmap;
}
public Argb8888Palette getPalette() {
return palette;
}
public void setPalette(Argb8888Palette palette) {
this.palette = palette;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to a new bitmap.
*/
public Argb8888ScanlineProcessor cloneWithNewBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to the same bitmap as the current processor.
*/
public Argb8888ScanlineProcessor cloneWithSharedBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, bitmap.makeView(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
// public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
// throw new IllegalStateException("TODO: override");
// }
abstract Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap);
}

View File

@@ -0,0 +1,45 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* Common functionality for Argb8888Director implementations.
*/
public abstract class BasicArgb8888Director<ResultT> implements Argb8888Director<ResultT> {
protected Argb8888ScanlineProcessor scanlineProcessor;
@Override
public void receivePalette(Argb8888Palette palette) {
scanlineProcessor.setPalette(palette);
}
@Override
public void processTransparentPalette(byte[] bytes, int position, int length) throws PngException {
Argb8888Palette palette = scanlineProcessor.getPalette();
if (null == palette) {
throw new PngIntegrityException("Received tRNS data but no palette is in place");
}
if (length <= 0 || length > palette.size()) {
throw new PngIntegrityException(String.format("Received tRNS data length is invalid. Should be >1 && < %d but is %d", palette.size(), length));
}
for (int i = 0; i < length; i++) {
final int alpha = 0xff & bytes[position + i];
palette.argbArray[i] = alpha << 24 | palette.argbArray[i] & 0x00FFFFFF;
}
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) throws PngException {
scanlineProcessor.processTransparentGreyscale(k1, k0);
}
@Override
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException {
scanlineProcessor.processTransparentTruecolour(r1, r0, g1, g0, b1, b0);
}
}

View File

@@ -0,0 +1,65 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* This will build a single bitmap: the default image within the PNG file.
* Any animation data (or any other data) will not be processed.
*/
public class DefaultImageArgb8888Director extends BasicArgb8888Director<Argb8888Bitmap> {
protected Argb8888Bitmap defaultImage;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
defaultImage = new Argb8888Bitmap(header.width, header.height);
scanlineProcessor = Argb8888Processors.from(header, buffer, defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return false;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
return null;
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
}
@Override
public Argb8888Bitmap getResult() {
return defaultImage;
}
}

View File

@@ -0,0 +1,30 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
/**
* A PngAnimationControl object contains data parsed from the ``acTL``
* animation control chunk of an animated PNG file.
*/
public class PngAnimationControl {
public final int numFrames;
public final int numPlays;
public PngAnimationControl(int numFrames, int numPlays) {
this.numFrames = numFrames;
this.numPlays = numPlays;
}
public boolean loopForever() {
return 0 == numPlays;
}
@Override
public String toString() {
return "PngAnimationControl{" +
"numFrames=" + numFrames +
", numPlays=" + numPlays +
'}';
}
}

View File

@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.util.ArrayList;
import java.util.List;
/**
* A PngFrameControl object contains data parsed from the ``fcTL`` chunk data
* in an animated PNG File.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
* <pre>
* 0 sequence_number (unsigned int) Sequence number of the animation chunk, starting from 0
* 4 width (unsigned int) Width of the following frame
* 8 height (unsigned int) Height of the following frame
* 12 x_offset (unsigned int) X position at which to render the following frame
* 16 y_offset (unsigned int) Y position at which to render the following frame
* 20 delay_num (unsigned short) Frame delay fraction numerator
* 22 delay_den (unsigned short) Frame delay fraction denominator
* 24 dispose_op (byte) Type of frame area disposal to be done after rendering this frame
* 25 blend_op (byte) Type of frame area rendering for this frame
* </pre>
* <p>
* Delay denominator: from spec, "if denominator is zero it should be treated as 100ths of second".
* <pre>
* dispose op:
* value
* 0 APNG_DISPOSE_OP_NONE
* 1 APNG_DISPOSE_OP_BACKGROUND
* 2 APNG_DISPOSE_OP_PREVIOUS
*
* blend op:
* value
* 0 APNG_BLEND_OP_SOURCE
* 1 APNG_BLEND_OP_OVER
* </pre>
*/
public class PngFrameControl {
public final int sequenceNumber;
public final int width;
public final int height;
public final int xOffset;
public final int yOffset;
public final short delayNumerator;
public final short delayDenominator;
public final byte disposeOp;
public final byte blendOp;
List<PngChunkMap> imageChunks = new ArrayList<>(1); // TODO: this may be removed
public PngFrameControl(int sequenceNumber, int width, int height, int xOffset, int yOffset, short delayNumerator, short delayDenominator, byte disposeOp, byte blendOp) {
this.sequenceNumber = sequenceNumber;
this.width = width;
this.height = height;
this.xOffset = xOffset;
this.yOffset = yOffset;
this.delayNumerator = delayNumerator;
this.delayDenominator = delayDenominator == 0 ? 100 : delayDenominator; // APNG spec says zero === 100.
this.disposeOp = disposeOp;
this.blendOp = blendOp;
}
/**
* @return number of milliseconds to show this frame for
*/
public int getDelayMilliseconds() {
if (delayDenominator == 1000) {
return delayNumerator;
} else {
// if denom is 100 then need to multiple by 10
float f = 1000 / delayDenominator; // 1000/100 -> 10
return (int) (delayNumerator * f);
}
}
// TODO: can this be removed?
public void appendImageData(PngChunkMap chunkMap) {
imageChunks.add(chunkMap);
}
// TODO: this may be removed
public List<PngChunkMap> getImageChunks() {
return imageChunks;
}
@Override
public String toString() {
return "PngFrameControl{" +
"sequenceNumber=" + sequenceNumber +
", width=" + width +
", height=" + height +
", xOffset=" + xOffset +
", yOffset=" + yOffset +
", delayNumerator=" + delayNumerator +
", delayDenominator=" + delayDenominator +
", disposeOp=" + disposeOp +
", blendOp=" + blendOp +
'}';
}
// mainly for ease in unit testing
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngFrameControl that = (PngFrameControl) o;
if (sequenceNumber != that.sequenceNumber) return false;
if (width != that.width) return false;
if (height != that.height) return false;
if (xOffset != that.xOffset) return false;
if (yOffset != that.yOffset) return false;
if (delayNumerator != that.delayNumerator) return false;
if (delayDenominator != that.delayDenominator) return false;
if (disposeOp != that.disposeOp) return false;
return blendOp == that.blendOp;
}
@Override
public int hashCode() {
int result = sequenceNumber;
result = 31 * result + width;
result = 31 * result + height;
result = 31 * result + xOffset;
result = 31 * result + yOffset;
result = 31 * result + (int) delayNumerator;
result = 31 * result + (int) delayDenominator;
result = 31 * result + (int) disposeOp;
result = 31 * result + (int) blendOp;
return result;
}
}

View File

@@ -0,0 +1,22 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import java.io.DataInputStream;
import java.io.IOException;
/**
* A PngGamma object represents data parsed from a ``gAMA`` chunk.
*/
public class PngGamma {
public final int imageGamma;
public PngGamma(int imageGamma) {
this.imageGamma = imageGamma;
}
public static PngGamma from(DataInputStream dis) throws IOException {
return new PngGamma(dis.readInt());
}
}

View File

@@ -0,0 +1,224 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import java.io.DataInput;
import java.io.IOException;
/**
* Created by aellerton on 10/05/2015.
*/
public class PngHeader {
/**
* Number of pixels (columns) wide.
*/
public final int width;
/**
* Number of pixels (rows) high.
*/
public final int height;
/**
* The bitDepth is the number of bits for each <em>channel</em> of a given pixel.
* A better name might be "bitsPerPixelChannel" but the name "bitDepth" is used
* throughout the PNG specification.
* <p>
* A truecolour image with a bitDepth of 8 means that the red channel of a pixel
* has 8 bits (so 256 levels of red), green has 8 bits (256 levels of green), and
* blue has 8 bits (so 256 levels of green). That means the total bitsPerPixel
* for that bitmap will be 8+8+8 = 24.
* <p>
* A truecolour <em>with alpha</em> image with bitDepth of 8 will be the same
* except every alpha element of every pixel will have 8 bits (so 256 levels of
* alpha transparency), meaning that the total bitsPerPixel for that bitmap will
* be 8+8+8+8=32.
* <p>
* A truecolour with alpha image with <em>bitDepth of 16</em> means that each of
* red, green blue and alpha have 16-bits respectively, meaning that the total
* bitsPerPixel will be 16+16+16+16 = 64.
* <p>
* A greyscale image (no alpha) with bitDepth of 16 has only a grey channel for
* each pixel, so the bitsPerPixel will also be 16.
* <p>
* But a greyscale image <em>with alpha</em> with a bitDepth of 16 has a grey
* channel and an alpha channel, each with 16 bits so the bitsPerPixel will be
* 16+16=32.
* <p>
* As for palette-based images...
* <ul>
* <li>A monochrome image or image with 2 colour palette has bitDepth=1.</li>
* <li>An image with 4 colour palette has bitDepth=2.</li>
* <li>An image with 8 colour palette has bitDepth=3.</li>
* <li>An image with 16 colour palette has bitDepth=4.</li>
* <li>A greyscale image with 16 levels of gray <em>and an alpha channel</em>
* has bitDepth=4 and bitsPerPixel=8 because the gray and the alpha channel
* each have 4 bits.</li>
* </ul>
*
* @see #bitsPerPixel
*/
public final byte bitDepth;
/**
* Every PNG image must be exactly one of the standard types as defined by the
* PNG specification. Better names might have been "imageType" or "imageFormat"
* but the name "colourType" is used throughout the PNG spec.
*/
public final PngColourType colourType;
/**
* Compression type of the file.
* In practice this is redundant: it may be zip and nothing else.
*/
public final byte compressionMethod;
/**
* Filter method used by the file.
* In practice this is redundant because the filter types are set in the
* specification and have never been (and never will be) extended.
*/
public final byte filterMethod;
/**
* An image is either interlaced or not interlaced.
* At the time of writing only non-interlaced is supported by this library.
*/
public final byte interlaceMethod;
/**
* The number of bits that comprise a single pixel in this bitmap (or every
* frame if animated). This is distinct from bitDepth.
*
* @see #bitDepth
*/
public final int bitsPerPixel;
public final int bytesPerRow;
public final int filterOffset;
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType) {
this(width, height, bitDepth, colourType, (byte) 0, (byte) 0, (byte) 0);
}
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) {
this.width = width;
this.height = height;
this.bitDepth = bitDepth;
this.colourType = colourType;
this.compressionMethod = compressionMethod;
this.filterMethod = filterMethod;
this.interlaceMethod = interlaceMethod;
this.bitsPerPixel = bitDepth * colourType.componentsPerPixel;
this.bytesPerRow = PngReadHelper.calculateBytesPerRow(width, bitDepth, colourType, interlaceMethod);
//this.filterOffset = this.bitsPerPixel < 8 ? 1 : this.bitsPerPixel>>3; // minimum of 1 byte. RGB888 will be 3 bytes. RGBAFFFF is 8 bytes.
this.filterOffset = (this.bitsPerPixel + 7) >> 3; // libpng
// from pypng
// # Derived values
// # http://www.w3.org/TR/PNG/#6Colour-values
// colormap = bool(self.color_type & 1)
// greyscale = not (self.color_type & 2)
// alpha = bool(self.color_type & 4)
// color_planes = (3,1)[greyscale or colormap]
// planes = color_planes + alpha
}
@Override
public String toString() {
return "PngHeader{" +
"width=" + width +
", height=" + height +
", bitDepth=" + bitDepth +
", colourType=" + colourType +
", compressionMethod=" + compressionMethod +
", filterMethod=" + filterMethod +
", interlaceMethod=" + interlaceMethod +
'}';
}
public boolean isInterlaced() {
return interlaceMethod == 1;
}
public boolean isZipCompression() {
return compressionMethod == 0;
}
public boolean isGreyscale() {
return colourType == PngColourType.PNG_GREYSCALE | colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA;
}
/**
* @return true if the image type indicates there is an alpha value for every pixel.
* Note that this take into account any transparency or background chunk.
*/
public boolean hasAlphaChannel() {
return colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA | colourType == PngColourType.PNG_TRUECOLOUR_WITH_ALPHA;
}
public static PngHeader makeTruecolour(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR);
}
public static PngHeader makeTruecolourAlpha(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR_WITH_ALPHA);
}
public PngHeader adjustFor(PngFrameControl frame) {
if (frame == null) {
return this;
} else {
return new PngHeader(frame.width, frame.height, this.bitDepth, this.colourType, this.compressionMethod, this.filterMethod, this.interlaceMethod);
}
}
public static void checkHeaderParameters(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) throws PngException {
switch (bitDepth) {
case 1:
case 2:
case 4:
case 8:
case 16:
break; // all fine
default:
throw new PngIntegrityException("Invalid bit depth " + bitDepth);
}
// thanks to pypng
if (colourType.isIndexed() && bitDepth > 8) {
throw new PngIntegrityException(String.format(
"Indexed images (colour type %d) cannot have bitdepth > 8 (bit depth %d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (bitDepth < 8 && !colourType.supportsSubByteDepth()) {
throw new PngIntegrityException(String.format(
"Illegal combination of bit depth (%d) and colour type (%d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (interlaceMethod != 0) {
throw new PngFeatureException("Interlaced images are not yet supported");
}
}
public static PngHeader from(DataInput dis) throws IOException, PngException {
int width = dis.readInt();
int height = dis.readInt();
byte bitDepth = dis.readByte();
PngColourType colourType = PngColourType.fromByte(dis.readByte());
byte compressionMethod = dis.readByte();
byte filterMethod = dis.readByte();
byte interlaceMethod = dis.readByte();
checkHeaderParameters(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
return new PngHeader(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
}
}

View File

@@ -0,0 +1,61 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Palette;
import java.util.Arrays;
/**
* A PngPalette object represents an ordered array of RGB (888) colour tuples
* derived from a PLTE chunk.
* <p>
* WARNING: this class may not remain in the API.
* When implementing the Argb8888 decoders it seems clear that every output
* format will benefit from a specific palette implementation, so this attempt
* at a generic palette may be removed.
*
* @see Argb8888Palette
*/
public class PngPalette {
// TODO: should include alpha here? Can then store as int32s?
public final byte[] rgb888;
public final int[] rgba8888; // Including this duplicate for now. Not sure if will keep it.
public final int numColours;
public static final int LENGTH_RGB_BYTES = 3;
public static final int BYTE_INITIAL_ALPHA = 0xff;
public PngPalette(byte[] rgb888, int[] rgba8888) {
this.rgb888 = rgb888;
this.rgba8888 = rgba8888;
this.numColours = rgb888.length / 3;
}
public static PngPalette from(byte[] source, int first, int length) throws PngException {
if (length % LENGTH_RGB_BYTES != 0) {
throw new PngIntegrityException(String.format("Invalid palette data length: %d (not a multiple of 3)", length));
}
return new PngPalette(
Arrays.copyOfRange(source, first, first + length),
rgba8888From(source, first, length)
);
}
private static int[] rgba8888From(byte[] source, int first, int length) {
int last = first + length;
int numColours = length / 3;
int[] rgba8888 = new int[numColours];
int j = 0;
for (int i = first; i < last; i += LENGTH_RGB_BYTES) {
rgba8888[j] = source[i] << 24 | source[i + 1] << 16 | source[i + 2] << 8 | BYTE_INITIAL_ALPHA;
j++;
}
return rgba8888;
}
}

View File

@@ -0,0 +1,21 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
/**
* All exceptions in the library are a PngException or subclass of it.
*/
public class PngException extends Exception {
int code;
public PngException(int code, String message) {
super(message);
this.code = code;
}
public PngException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}

View File

@@ -0,0 +1,17 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
/**
* A PngFeatureException is thrown when a feature used in a specific file
* is not supported by the pipeline being used. For example, at the time
* of writing, interlaced image reading is not supported.
*/
public class PngFeatureException extends PngException {
public PngFeatureException(String message) {
super(PngConstants.ERROR_FEATURE_NOT_SUPPORTED, message);
}
}

View File

@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
/**
* A PngIntegrityException is thrown when some aspect of the PNG file being
* loaded is invalid or unacceptable according to the PNG Specification.
* <p>
* For example, requesting a 16-bit palette image is invalid, or a colour type
* outside of the allowed set.
*/
public class PngIntegrityException extends PngException {
public PngIntegrityException(String message) {
super(PngConstants.ERROR_INTEGRITY, message);
}
}

View File

@@ -0,0 +1,52 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
/**
* A single chunk from a PNG file is represented by a PngChunkMap.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngChunkMap {
/**
* The code like IHDR, IPAL, IDAT. If the chunk code is non-standard,
* the UNKNOWN code will be used and the codeString will be set.
*/
public PngChunkCode code;
/**
* Number of bytes containing the data portion of the chunk. Note that this excludes
* the last 4 bytes that are the CRC checksom.
*/
public int dataLength;
/**
* Integer offset of the first byte of data in this chunk (the byte immediately
* following the chunk length 32-bits and the chunk type 32-bits) from byte zero
* of the source.
*/
public int dataPosition;
public int checksum;
public PngChunkMap(PngChunkCode code, int dataPosition, int dataLength, int checksum) {
this.code = code;
this.dataLength = dataLength;
this.dataPosition = dataPosition;
this.checksum = checksum;
}
@Override
public String toString() {
return "PngChunkMap{" +
"letters=" + code +
", dataLength=" + dataLength +
", dataPosition=" + dataPosition +
", checksum=" + checksum +
'}';
}
}

View File

@@ -0,0 +1,17 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import java.util.List;
/**
* A PngMap represents a map over an entire single PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMap {
public String source;
public List<PngChunkMap> chunks;
}

View File

@@ -0,0 +1,50 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngSource;
import java.io.IOException;
import java.util.ArrayList;
/**
* Simple processor that skips all chunk content and ignores checksums, with
* sole objective of building a map of the contents of a PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMapReader implements PngReader<PngMap> {
PngMap map;
public PngMapReader(String sourceName) {
map = new PngMap();
map.source = sourceName;
map.chunks = new ArrayList<>(4);
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell();
source.skip(dataLength);
int chunkChecksum = source.readInt();
map.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
return code == PngConstants.IEND_VALUE;
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
// NOP
}
@Override
public PngMap getResult() {
return map;
}
}

View File

@@ -0,0 +1,4 @@
/**
* @see <a href="https://github.com/aellerton/japng">japng</a>
*/
package org.jackhuang.hmcl.ui.image.apng;

View File

@@ -0,0 +1,65 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.util.PartialInflaterInputStream;
import java.io.FilterInputStream;
import java.io.InputStream;
import java.util.zip.Inflater;
/**
* A BasicScanlineProcessor manages a re-entrant java.util.zip.Inflater object and
* basic bytesPerLine management.
*/
public abstract class BasicScanlineProcessor implements PngScanlineProcessor {
protected final int bytesPerLine;
protected Inflater inflater = new Inflater();
//protected PngHeader header;
//protected PngScanlineBuffer scanlineReader;
//protected PngFrameControl currentFrame;
//public DefaultScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
public BasicScanlineProcessor(int bytesPerScanline) {
// this.header = header.adjustFor(currentFrame);
// this.currentFrame = currentFrame;
// this.scanlineReader = scanlineReader;
// this.bytesPerLine = this.header.bytesPerRow;
this.bytesPerLine = bytesPerScanline;
}
//@Override
//abstract public void processScanline(byte[] bytes, int position);
@Override
public FilterInputStream makeInflaterInputStream(InputStream inputStream) {
return new PartialInflaterInputStream(inputStream, inflater);
}
@Override
public int getBytesPerLine() {
return bytesPerLine;
}
@Override
public boolean isFinished() {
return inflater.finished();
}
// @Override
// public InflaterInputStream connect(InputStream inputStream) {
// multipartStream.add(inputStream);
// return iis;
// }
@Override
public void processTransparentGreyscale(byte k1, byte k0) {
// NOP by default
}
@Override
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) {
// NOP by default
}
}

View File

@@ -0,0 +1,303 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngAnimationType;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
/**
* The DefaultPngChunkReader is the default PNG chunk-reading workhorse.
* <p>
* Note that any chunk types not recognised can be processed in the readOtherChunk()
* method.
*/
public class DefaultPngChunkReader<ResultT> implements PngChunkReader<ResultT> {
protected PngChunkProcessor<ResultT> processor;
protected boolean seenHeader = false;
protected int idatCount = 0;
protected int apngSequenceExpect = 0;
protected PngAnimationType animationType = PngAnimationType.NOT_ANIMATED;
//private PngMainImageOp mainImageOp = PngMainImageOp.MAIN_IMAGE_KEEP;
public DefaultPngChunkReader(PngChunkProcessor<ResultT> processor) {
this.processor = processor;
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell(); // note the start position before any further reads are done.
if (dataLength < 0) {
throw new PngIntegrityException(String.format("Corrupted read (Data length %d)", dataLength));
}
switch (code) {
case PngConstants.IHDR_VALUE:
readHeaderChunk(source, dataLength);
break;
case PngConstants.IEND_VALUE:
// NOP
break;
case PngConstants.gAMA_VALUE:
readGammaChunk(source, dataLength);
break;
case PngConstants.bKGD_VALUE:
readBackgroundChunk(source, dataLength);
break;
case PngConstants.tRNS_VALUE:
readTransparencyChunk(source, dataLength);
break;
case PngConstants.PLTE_VALUE:
readPaletteChunk(source, dataLength);
break;
case PngConstants.IDAT_VALUE:
readImageDataChunk(source, dataLength);
break;
case PngConstants.acTL_VALUE:
readAnimationControlChunk(source, dataLength);
break;
case PngConstants.fcTL_VALUE:
readFrameControlChunk(source, dataLength);
break;
case PngConstants.fdAT_VALUE:
readFrameImageDataChunk(source, dataLength);
break;
default:
readOtherChunk(code, source, dataPosition, dataLength);
break;
}
int chunkChecksum = source.readInt();
processChunkEnd(code, dataPosition, dataLength, chunkChecksum);
return code == PngConstants.IEND_VALUE;
}
@Override
public void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException {
processor.processChunkMapItem(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
//container.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataLength, dataPosition, chunkChecksum));
}
@Override
public void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException {
PngHeader header = PngHeader.from(source.getDis());
seenHeader = true;
processor.processHeader(header); // Hmm. Prefer header = PngHeader.from(source) ?
}
@Override
public void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException {
processor.processGamma(PngGamma.from(source.getDis()));
}
@Override
public void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException {
// TODO
//processor.processTransparency(PngTransparency.from(source, dataLength));
//throw new PngFeatureException("TODO UP TO HERE");
//source.skip(dataLength);
// if (dataLength % 3 != 0) {
// throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
// }
if (source.supportsByteAccess()) {
processor.processTransparency(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processTransparency(paletteBytes, 0, dataLength);
}
}
@Override
public void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException {
if (!seenHeader) {
throw new PngIntegrityException("bKGD chunk received before IHDR chunk");
}
// TODO
//processor.processBackground(PngBackground.from(source, dataLength);
source.skip(dataLength);
}
@Override
public void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength % 3 != 0) {
throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
}
// TODO: can check if colour type matches palette type, or if any palette received before (overkill?)
if (source.supportsByteAccess()) {
processor.processPalette(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processPalette(paletteBytes, 0, dataLength);
}
}
@Override
public void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException {
if (idatCount == 0 && apngSequenceExpect == 0) {
// Processing a plain PNG (non animated) IDAT chunk
animationType = PngAnimationType.NOT_ANIMATED; // processor.chooseApngImageType(PngAnimationType.NOT_ANIMATED, null);
}
idatCount++;
switch (animationType) {
case ANIMATED_DISCARD_DEFAULT_IMAGE:
// do nothing
source.skip(dataLength);
break;
case ANIMATED_KEEP_DEFAULT_IMAGE:
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
case NOT_ANIMATED:
default:
processor.processDefaultImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
}
// source.skip(dataLength);
}
@Override
public void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_acTL_CHUNK) {
throw new PngIntegrityException(String.format("acTL chunk length must be %d, not %d", PngConstants.LENGTH_acTL_CHUNK, dataLength));
}
processor.processAnimationControl(new PngAnimationControl(source.readInt(), source.readInt()));
}
@Override
public void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_fcTL_CHUNK) {
throw new PngIntegrityException(String.format("fcTL chunk length must be %d, not %d", PngConstants.LENGTH_fcTL_CHUNK, dataLength));
}
int sequence = source.readInt(); // TODO: check sequence # is correct or PngIntegrityException
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fctl chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // ready for next time
PngFrameControl frame = new PngFrameControl(
sequence,
source.readInt(), // width
source.readInt(), // height
source.readInt(), // x offset
source.readInt(), // y offset
source.readUnsignedShort(), // delay numerator
source.readUnsignedShort(), // delay denominator
source.readByte(), // dispose op
source.readByte() // blend op
);
if (sequence == 0) { // We're at the first frame...
if (idatCount == 0) { // Not seen any IDAT chunks yet
// APNG Spec says that when the first fcTL chunk is received *before* the first IDAT chunk
// the main image of the PNG becomes the first frame in the animation.
animationType = PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE; //processor.chooseApngImageType(PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE, frame);
//mainImageOp = PngMainImageOp.MAIN_IMAGE_STARTS_ANIMATION;
} else {
//mainImageOp = PngMainImageOp.MAIN_IMAGE_DISCARD;
animationType = PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE; // processor.chooseApngImageType(PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE, frame);
}
} else {
// fall through
}
processor.processFrameControl(frame);
}
//public abstract void setMainImageOp(PngMainImageOp op);
@Override
public void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException {
// Note that once the sequence number is confirmed as being correct that there
// is no need to retain it in subsequent data.
int position = source.tell();
int sequence = source.readInt();
dataLength -= 4; // After reading the sequence number the data is just like IDAT.
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fdAT chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // for next time
//processFrameData(sequence, source, dataLength);
//processFrameImageData(source, dataLength);
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.fdAT, source.tell(), dataLength);
// PngFrameControl current = container.getCurrentAnimationFrame();
//
// //imageDecoder
// // TODO: send image bytes to digester
// current.appendImageData(new PngChunkMap(PngChunkCode.fdAT, dataLength, position, 0));
//
// // TODO: skip everything except the frame sequence number
// source.skip(dataLength);
}
@Override
public void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException {
// If we're not processing it, got to skip it.
source.skip(dataLength);
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
}
@Override
public ResultT getResult() {
return processor.getResult();
}
public boolean isSeenHeader() {
return seenHeader;
}
public int getIdatCount() {
return idatCount;
}
public int getApngSequenceExpect() {
return apngSequenceExpect;
}
public PngAnimationType getAnimationType() {
return animationType;
}
}

View File

@@ -0,0 +1,109 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* A simple way to process a PNG file is to read the entire thing into memory.
* <p>
* Very bad for very large images (500Mb?)? Yes.
* But simple for smaller (say <10Mb?) images? Also yes.
* I'd certainly like to make a lean alternative that processes bytes without reading
* the whole thing into memory, but it can come later. Patches welcome.
* <p>
* WARNING: I'm not sure I'll keep this. I may remove it and do everything with
* a BufferedInputStream + DataInputStream.
* </p>
*/
public class PngAtOnceSource implements PngSource {
final byte[] bytes;
//private String sourceDescription;
private final ByteArrayInputStream bis;
private final DataInputStream dis;
public PngAtOnceSource(byte[] bytes) { //}, String sourceDescription) {
this.bytes = bytes;
//this.sourceDescription = sourceDescription;
this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
this.dis = new DataInputStream(this.bis); // never closed because underlying stream doesn't need to be closed.
}
@Override
public int getLength() {
return bytes == null ? 0 : bytes.length;
}
@Override
public boolean supportsByteAccess() {
return true;
}
@Override
public byte[] getBytes() throws IOException {
return bytes;
}
@Override
public byte readByte() throws IOException {
return dis.readByte();
}
@Override
public short readUnsignedShort() throws IOException {
return (short) dis.readUnsignedShort();
}
@Override
public int readInt() throws IOException {
return dis.readInt();
}
@Override
public long skip(int chunkLength) throws IOException {
return dis.skip(chunkLength);
}
@Override
public int tell() {
return bytes.length - bis.available();
}
@Override
public int available() {
return bis.available();
}
// @Override
// public String getSourceDescription() {
// return sourceDescription;
// }
// @Override
// public ByteArrayInputStream getBis() {
// return bis;
// }
@Override
public DataInputStream getDis() {
return dis;
}
@Override
public InputStream slice(int dataLength) throws IOException {
// The below would be fine but in this case we have the full byte stream anyway...
//return ByteStreams.limit(bis, dataLength);
InputStream slice = new ByteArrayInputStream(bytes, tell(), dataLength);
this.skip(dataLength);
return slice;
}
public static PngAtOnceSource from(InputStream is) throws IOException {
return new PngAtOnceSource(is.readAllBytes());
}
}

View File

@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
import java.io.InputStream;
/**
* While a PngChunkReader does the low-level reading of a PNG file, it delegates <em>processing</em> of
* the data to a PngChunkProcessor instance. The class is generic about a specific result type because
* the intention of a chunk processor is to yield some result, which might be a bitmap, a sequence of
* bitmaps, a map of chunks, etc.
*/
public interface PngChunkProcessor<ResultT> {
/**
* Subclasses can further process the header immediately after PngHeader has been read.
* A subclass might elect to bail out of subsequent processing, e.g. if the image is too big
* or has an invalid format.
*
* @param header of image
*/
void processHeader(PngHeader header) throws PngException;
/**
* Process the PngGamma object parsed from a ``gaMA`` chunk.
*
* @param gamma parsed gamma data.
* @throws PngException
*/
void processGamma(PngGamma gamma) throws PngException;
/**
* Process the raw trNS data from the PNG file.
* <p>
* Note that this data is passed raw (but in a complete byte array).
* It is not parsed into an object because it allows concrete implementations to define
* their own way to handle the data. And it is not provided as in InputStream because
* the length of the data is well defined based on the PNG colour type.
*
* @param bytes array to read data from.
* @param position offset into the array to begin reading data from.
* @param length number of bytes to read from the array.
* @throws PngException
*/
void processTransparency(byte[] bytes, int position, int length) throws PngException;
/**
* A PLTE chunk has been read and needs to be processed.
* <p>
* A byte array is provided to process. A pre-defined palette class is not loaded because
* different rendering targets may elect to load the palette in different ways.
* For example, rendering to an ARGB palette may load the RGB data and organise as
* an array of 32-bit integer ARGB values.
* </p>
*
* @param bytes representing the loaded palette data. Each colour is represented by three
* bytes, 1 each for red, green and blue.
* @param position that the colour values begin in bytes
* @param length number of bytes that the palette bytes continue. Guaranteed to be a
* multiple of 3.
* @throws PngException
*/
void processPalette(byte[] bytes, int position, int length) throws PngException;
/**
* Process one IDAT chunk the "default image" bitmap in a given file.
* <p>
* The "default", "main" or "primary image" of a file is what you'd generally
* think of as "the" image in file, namely the image constructed of IDAT chunks
* and displayed by any normal viewer or browser.
* <p>
* I theorise (without proof) that most PNG files in the wild have a single
* IDAT chunk representing all pixels in the main image, but the PNG specification
* requires loaders to handle the case where there are multiple IDAT chunks.
* In the case of multiple chunks the data continues precisely where the last
* IDAT chunk left off. The same applies to fdAT chunks.
* <p>
* The data is provided not in any pre-parsed object or even as a byte array because
* the bytes can be arbitrarily long. The InputStream provided is a slice of the
* containing source and will terminate at the end of the IDAT chunk.
*
* @param inputStream data source containing all bytes in the image data chunk.
* @param code of the chunk which should always be IDAT in this case.
* Compare to this.processFrameImageData.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
void processAnimationControl(PngAnimationControl animationControl) throws PngException;
void processFrameControl(PngFrameControl frameControl) throws PngException;
/**
* The reader has determined that bitmap data needs to be processed for an animation frame.
* The image data may be from an IDAT chunk or an fdAT chunk.
* <p>
* Whether the "default image" in a PNG file is to be part of the animation or discarded
* is determed by the placement of the first fcTL chunk in the file. See the APNG specification.
*
* @param inputStream data source containing all bytes in the frame image data chunk.
* @param code either IDAT or fdAT. Designed to allow an implementation to detect whether the
* frame is from the "main image" or a subsequent image. May not be necessary.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
/**
* A subclass can record data about the chunk here.
* <p>
* WARNING: this API may be removed. I'm not sure if it is useful.
*
* @param chunkMapItem represents a "map" of a single chunk in the file.
* @throws PngException
*/
void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException;
/**
* @return the result of processing all chunks. Only ever called after the file is fully read.
*/
ResultT getResult();
}

View File

@@ -0,0 +1,134 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* Created by aellerton on 15/05/2015.
*/
public interface PngChunkReader<ResultT> extends PngReader<ResultT> {
@Override
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException;
void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException;
void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException;
/**
* Process the tRNS chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11tRNS:
* <p>
* The tRNS chunk specifies either alpha values that are associated with palette entries (for indexed-colour
* images) or a single transparent colour (for greyscale and truecolour images). The tRNS chunk contains:
* <p>
* Colour type 0:
* <p>
* Grey sample value 2 bytes
* <p>
* Colour type 2:
* <p>
* Red sample value 2 bytes
* Blue sample value 2 bytes
* Green sample value 2 bytes
* <p>
* Colour type 3:
* <p>
* Alpha for palette index 0 1 byte
* Alpha for palette index 1 1 byte
* ...etc... 1 byte
* <p>
* Note that for palette colour types the number of transparency entries may be less than the number of
* entries in the palette. In that case all missing entries are assumed to be fully opaque.
*
* @param source
* @param dataLength
*/
void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Process the bKGD chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11bKGD:
* <p>
* The bKGD chunk specifies a default background colour to present the image against.
* If there is any other preferred background, either user-specified or part of a larger
* page (as in a browser), the bKGD chunk should be ignored. The bKGD chunk contains:
* <p>
* Colour types 0 and 4
* Greyscale 2 bytes
* Colour types 2 and 6
* Red 2 bytes
* Green 2 bytes
* Blue 2 bytes
* Colour type 3
* Palette index 1 byte
* <p>
* For colour type 3 (indexed-colour), the value is the palette index of the colour to be used as background.
* <p>
* For colour types 0 and 4 (greyscale, greyscale with alpha), the value is the grey level to be used as
* background in the range 0 to (2bitdepth)-1. For colour types 2 and 6 (truecolour, truecolour with alpha),
* the values are the colour to be used as background, given as RGB samples in the range 0 to (2bitdepth)-1.
* In each case, for consistency, two bytes per sample are used regardless of the image bit depth. If the image
* bit depth is less than 16, the least significant bits are used and the others are 0.
*
* @param source
* @param dataLength
* @throws IOException
*/
void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException;
void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the IDAT chunk.
* <p>
* The default implementation skips the data, deferring to the finishedChunks() method
* to process the data. Key reasons to do this:
* <ul>
* <li>There could be multiple IDAT chunks and the streams need to
* be concatenated. This could be done in a single pass but it's more
* complicated and is not the objective of this implementation.</li>
* <li>This might be an APNG file and the IDAT chunk(s) are to be skipped.</li>
* </ul>
*
* @param source to read from
* @param dataLength in bytes of the data
* @throws PngException
* @throws IOException
*/
void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException;
void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the fcTL chunk.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
*
* @param source
* @param dataLength
* @throws IOException
* @throws PngException
*/
void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException;
void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Give subclasses the opportunity to process a chunk code that was not recognised.
*
* @param code chunk type as integer.
* @param source of PNG data, positioned to read the data bytes.
* @param dataPosition offset from absolute start of bytes that data beings.
* @param dataLength of this chunk
* @throws IOException
*/
void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException;
}

View File

@@ -0,0 +1,112 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
/**
* PNG reading support functions.
*/
public class PngReadHelper {
/**
* Return true if the next 8 bytes of the InputStream match the
* standard 8 byte PNG Signature, and false if they do not match.
* <p>
* If the stream ends before the signature is read an EOFExceptoin is thrown.
* </p><p>
* Note that no temporary buffer is allocated.
* </p>
*
* @param is Stream to read the 8-byte PNG signature from
* @throws IOException in the case of any IO exception, including EOF.
*/
public static boolean readSignature(InputStream is) throws IOException {
for (int i = 0; i < PngConstants.LENGTH_SIGNATURE; i++) {
int b = is.read();
if (b < 0) {
throw new EOFException();
}
if ((byte) b != PngConstants.BYTES_SIGNATURE[i]) {
return false;
}
}
return true;
}
/**
* Reads a given InputStream using the PngReader to process all chunks until the file is
* finished, then returning the result from the PngReader.
*
* @param is stream to read
* @param reader reads and delegates processing of all chunks
* @param <ResultT> result of the processing
* @return result of the processing of the InputStream.
* @throws PngException
*/
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
try {
if (!PngReadHelper.readSignature(is)) {
throw new PngException(PngConstants.ERROR_NOT_PNG, "Failed to read PNG signature");
}
// PngAtOnceSource source = PngAtOnceSource.from(is);//, sourceName);
PngSource source = new PngStreamSource(is);
boolean finished = false;
while (!finished) {
int length = source.readInt();
int code = source.readInt();
finished = reader.readChunk(source, code, length);
}
if (source.available() > 0) { // Should trailing data after IEND always be error or can configure as warning?
throw new PngException(PngConstants.ERROR_EOF_EXPECTED, String.format("Completed IEND but %d byte(s) remain", source.available()));
}
reader.finishedChunks(source);
return reader.getResult();
} catch (EOFException e) {
throw new PngException(PngConstants.ERROR_EOF, "Unexpected EOF", e);
} catch (IOException e) {
throw new PngException(PngConstants.ERROR_UNKNOWN_IO_FAILURE, e.getMessage(), e);
}
}
/**
* Number of bytes per row is key to processing scanlines.
* <p>
* TODO: should this by on the header?
*/
public static int calculateBytesPerRow(int pixelsPerRow, byte bitDepth, PngColourType colourType, byte interlaceMethod) {
if (interlaceMethod != 0) {
throw new IllegalStateException("Interlaced images not yet supported");
} else {
int numComponentsPerPixel = colourType.componentsPerPixel; // or "channels". e.g. "gray and alpha" means two components.
int bitsPerComponent = bitDepth; // e.g. "4" means 4 bits for gray, 4 bits for alpha
int bitsPerPixel = bitsPerComponent * numComponentsPerPixel; // e.g. total of 8 bits per pixel
int bitsPerRow = bitsPerPixel * pixelsPerRow;
//?? use that (bitDepth+7)>>3 ... thing?
// unsigned int bpp = (row_info->pixel_depth + 7) >> 3; // libpng
// If there are less than 8 bits per pixel, then ensure the last byte of the row is padded.
int bytesPerRow = bitsPerRow / 8 + ((0 == (bitsPerRow % 8)) ? 0 : (8 - bitsPerRow % 8));
return 1 + bytesPerRow; // need 1 byte for filter code
}
}
private PngReadHelper() {
}
}

View File

@@ -0,0 +1,20 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* All PngReader implementations need to read a specific single chunk and to return
* a result of some form.
*/
public interface PngReader<ResultT> {
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void finishedChunks(PngSource source) throws PngException, IOException;
ResultT getResult();
}

View File

@@ -0,0 +1,69 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.FilterInputStream;
import java.io.InputStream;
/**
* A PngScanlineProcessor receives decompressed, de-filtered scanlines, one by one, in order
* from top to bottom. Interlaced scanlines are not yet supported. The scanline data is not
* parsed or expanded in any form, so if the PNG is 1-bit monochrome or 16 bit RGBA, the bytes
* are provided as-is. The job of the processor is to read and reformat each pixel as
* appropriate for the destination bitmap.
* <p>
* Note that a single instance of a PngScanlineProcessor is intended to process a single
* bitmap and that, when the bitmap is complete, the isFinished() method will return true.
* In the case of an animated PNG, it is expected that a new PngScanlineProcessor will be
* created to service the new bitmap.
* <p>
* Note: I wonder if this would be better named PngScanlineTransformer because its primary
* purpose is to convert pixels from raw file format to destination format.
*/
public interface PngScanlineProcessor {
/**
* A PngScanlineProcessor is responsible for decompressing the raw (compressed) data.
* This is important because there can be more than one IDAT and fdAT chunks for a single
* image, and decompression must occur seamlessly across those chunks, so the decompression
* must be stateful across multiple invocations of makeInflaterInputStream. In practice, it
* seems to be rare that there are multiple image data chunks for a single image.
*
* @param inputStream stream over raw compressed PNG image data.
* @return an InflaterInputStream that decompresses the current stream.
*/
FilterInputStream makeInflaterInputStream(InputStream inputStream);
/**
* The processScanline method is invoked when the raw image data has been first decompressed
* then de-filtered. The PngScanlineProcessor then must interpret each byte according to the
* specific image format and render it to the destination as appropriate.
*
* @param bytes decompressed, de-filtered bytes, ready for processing.
* @param position the position that scanline pixels begin in the array. Note that it is
* the responsibility of the PngScanlineProcessor to know exactly how many
* bytes are in the row. This is because a single processor might process
* different frames in an APNG, and each frame can have a different width
* (up to the maximum set in the PNG header).
*/
void processScanline(byte[] bytes, int position);
int getBytesPerLine();
void processTransparentGreyscale(byte k1, byte k0);
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0);
/**
* A PngScanlineProcessor must be able to decompress more than one consecutive IDAT or fdAT
* chunks. This is because the PNG specification states that a valid PNG file can have more
* than one consecutive IDAT (and by extension, fdAT) chunks and the data therein must be
* treated as if concatenated.
*
* @return true when the data for the current bitmap is complete.
*/
boolean isFinished();
//InflaterInputStream connect(InputStream inputStream);
}

View File

@@ -0,0 +1,43 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* The API for reading any PNG source.
*
* @see PngAtOnceSource
* <p>
* WARNING: this may be removed in favour of direct use of InputStream objects.
*/
public interface PngSource {
int getLength();
boolean supportsByteAccess();
byte[] getBytes() throws IOException;
byte readByte() throws IOException;
short readUnsignedShort() throws IOException;
int readInt() throws IOException;
long skip(int chunkLength) throws IOException;
int tell();
int available();
// String getSourceDescription();
// ByteArrayInputStream getBis();
DataInputStream getDis();
InputStream slice(int dataLength) throws IOException;
}

View File

@@ -0,0 +1,91 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.util.InputStreamSlice;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* Created by aellerton on 13/06/2015.
*/
public class PngStreamSource implements PngSource {
final InputStream src;
//private final ByteArrayInputStream bis;
private final DataInputStream dis;
public PngStreamSource(InputStream src) {
this.src = src;
//this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
this.dis = new DataInputStream(this.src); // never closed because underlying stream doesn't need to be closed.
}
@Override
public int getLength() {
return 0; // TODO?
}
@Override
public boolean supportsByteAccess() {
return false;
}
@Override
public byte[] getBytes() throws IOException {
return null;
}
@Override
public byte readByte() throws IOException {
return dis.readByte();
}
@Override
public short readUnsignedShort() throws IOException {
return (short) dis.readUnsignedShort();
}
@Override
public int readInt() throws IOException {
return dis.readInt();
}
@Override
public long skip(int chunkLength) throws IOException {
return dis.skip(chunkLength);
}
@Override
public int tell() {
return 0; // TODO
}
@Override
public int available() {
try {
return dis.available(); // TODO: adequate?
} catch (IOException e) {
return 0; // TODO
}
}
// @Override
// public ByteArrayInputStream getBis() {
// return null;
// }
@Override
public DataInputStream getDis() {
return dis;
}
@Override
public InputStream slice(int dataLength) {
return new InputStreamSlice(src, dataLength);
}
}

View File

@@ -0,0 +1,95 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import java.io.IOException;
import java.io.InputStream;
/**
* A specific-length slice of some other InputStream.
*/
public class InputStreamSlice extends InputStream {
protected final InputStream src;
protected final int length;
protected int position = 0;
protected boolean atEof = false;
public InputStreamSlice(InputStream src, int length) {
this.src = src;
this.length = length;
}
public int tell() {
return position;
}
@Override
public int read(byte[] b) throws IOException {
if (atEof || position >= length) {
atEof = true;
return -1;
}
int rv = src.read(b, 0, Math.min(b.length, length - position));
if (rv > 0) {
position += rv;
}
return rv;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (atEof || position >= length) {
atEof = true;
return -1;
}
int rv = src.read(b, off, Math.min(len, length - position));
if (rv > 0) {
position += rv;
}
return rv;
}
@Override
public long skip(long n) throws IOException {
if (atEof || position >= length) {
atEof = true;
return -1;
}
n = Math.min(available(), n); // calculate maximum skip
long remaining = n;
while (remaining > 0) {
long skipped = src.skip(remaining); // attempt to skip that much
if (skipped <= 0) {
throw new IOException("Failed to skip a total of " + n + " bytes in stream; " + remaining + " bytes remained but " + skipped + " returned from skip.");
}
remaining -= skipped;
}
position += n; // adjust position by correct skip
return n;
}
@Override
public int available() throws IOException {
if (atEof || position >= length) {
return 0;
}
return length - position;
}
@Override
public int read() throws IOException {
if (atEof || position >= length) {
atEof = true;
return -1;
}
int rv = src.read();
if (rv < 0) {
atEof = true;
} else if (rv > 0) {
this.position += rv;
}
return rv;
}
}

View File

@@ -0,0 +1,308 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipException;
/**
* A hacked copy of the Java standard java.util.zip.InflaterInputStream that
* is modified to work with files that have multiple IDAT (or fdAT) chunks for
* a single bitmap.
* <p>
* The default java.util.zip.InflaterInputStream by David Connelly works a treat
* except that if the java.util.zip.Inflater is expecting data when the stream
* runs out of data then the stream will die with an exception.
* <p>
* The PartialInflaterInputStream, on the other hand, quietly allows the EOF
* to be returned and leaves the Inflater in the "waiting for more data" data,
* ready to pick up when the next IDAT (or fdAT) chunk starts.
* <p>
* If you're wondering why I didn't just subclass InflaterInputStream, there are
* a few reasons. The main change is in the ``fill()`` method, which needs to
* call the method ``ensureOpen()`` but can't because it is private. It is also
* ideal to return an int but the original ``fill`` method is void. Then there
* is a change to the ``read()`` method and that references a number of private
* fields. In short, monkeying around in an attempt to reuse the original seemed
* more expensive than just copying the original code.
* <p>
* The original class documentation states:
* "This class implements a stream filter for uncompressing data in the
* "deflate" compression format. It is also used as the basis for other
* decompression filters, such as GZIPInputStream."
* </blockquote>
*
* @see InflaterInputStream
*/
public class PartialInflaterInputStream extends FilterInputStream {
/**
* Decompressor for this stream.
*/
protected Inflater inf;
/**
* Input buffer for decompression.
*/
protected byte[] buf;
/**
* Length of input buffer.
*/
protected int len;
private boolean closed = false;
// this flag is set to true after EOF has reached
private boolean reachEOF = false;
/**
* Check to make sure that this stream has not been closed
*/
private void ensureOpen() throws IOException {
if (closed) {
throw new IOException("Stream closed");
}
}
/**
* Creates a new input stream with the specified decompressor and
* buffer size.
*
* @param in the input stream
* @param inf the decompressor ("inflater")
* @param size the input buffer size
* @throws IllegalArgumentException if size is <= 0
*/
public PartialInflaterInputStream(InputStream in, Inflater inf, int size) {
super(in);
if (in == null || inf == null) {
throw new NullPointerException();
} else if (size <= 0) {
throw new IllegalArgumentException("buffer size <= 0");
}
this.inf = inf;
buf = new byte[size];
}
/**
* Creates a new input stream with the specified decompressor and a
* default buffer size.
*
* @param in the input stream
* @param inf the decompressor ("inflater")
*/
public PartialInflaterInputStream(InputStream in, Inflater inf) {
this(in, inf, 512);
}
boolean usesDefaultInflater = false;
/**
* Creates a new input stream with a default decompressor and buffer size.
*
* @param in the input stream
*/
public PartialInflaterInputStream(InputStream in) {
this(in, new Inflater());
usesDefaultInflater = true;
}
private byte[] singleByteBuf = new byte[1];
/**
* Reads a byte of uncompressed data. This method will block until
* enough input is available for decompression.
*
* @return the byte read, or -1 if end of compressed input is reached
* @throws IOException if an I/O error has occurred
*/
public int read() throws IOException {
ensureOpen();
return read(singleByteBuf, 0, 1) == -1 ? -1 : singleByteBuf[0] & 0xff;
}
/**
* Reads uncompressed data into an array of bytes. If <code>len</code> is not
* zero, the method will block until some input can be decompressed; otherwise,
* no bytes are read and <code>0</code> is returned.
*
* @param b the buffer into which the data is read
* @param off the start offset in the destination array <code>b</code>
* @param len the maximum number of bytes read
* @return the actual number of bytes read, or -1 if the end of the
* compressed input is reached or a preset dictionary is needed
* @throws NullPointerException If <code>b</code> is <code>null</code>.
* @throws IndexOutOfBoundsException If <code>off</code> is negative,
* <code>len</code> is negative, or <code>len</code> is greater than
* <code>b.length - off</code>
* @throws ZipException if a ZIP format error has occurred
* @throws IOException if an I/O error has occurred
*/
public int read(byte[] b, int off, int len) throws IOException {
ensureOpen();
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
try {
int n;
while ((n = inf.inflate(b, off, len)) == 0) {
if (inf.finished() || inf.needsDictionary()) {
reachEOF = true;
return -1;
}
if (inf.needsInput()) {
n = fill();
if (n <= 0) { // TODO: or only < 0?
return -1;
}
}
}
return n;
} catch (DataFormatException e) {
String s = e.getMessage();
throw new ZipException(s != null ? s : "Invalid ZLIB data format");
}
}
/**
* Returns 0 after EOF has been reached, otherwise always return 1.
* <p>
* Programs should not count on this method to return the actual number
* of bytes that could be read without blocking.
*
* @return 1 before EOF and 0 after EOF.
* @throws IOException if an I/O error occurs.
*
*/
public int available() throws IOException {
ensureOpen();
if (reachEOF) {
return 0;
} else {
return 1;
}
}
private byte[] b = new byte[512];
/**
* Skips specified number of bytes of uncompressed data.
*
* @param n the number of bytes to skip
* @return the actual number of bytes skipped.
* @throws IOException if an I/O error has occurred
* @throws IllegalArgumentException if n < 0
*/
public long skip(long n) throws IOException {
if (n < 0) {
throw new IllegalArgumentException("negative skip length");
}
ensureOpen();
int max = (int) Math.min(n, Integer.MAX_VALUE);
int total = 0;
while (total < max) {
int len = max - total;
if (len > b.length) {
len = b.length;
}
len = read(b, 0, len);
if (len == -1) {
reachEOF = true;
break;
}
total += len;
}
return total;
}
/**
* Closes this input stream and releases any system resources associated
* with the stream.
*
* @throws IOException if an I/O error has occurred
*/
public void close() throws IOException {
if (!closed) {
if (usesDefaultInflater)
inf.end();
in.close();
closed = true;
}
}
/**
* Fills input buffer with more data to decompress.
*
* @throws IOException if an I/O error has occurred
*/
protected int fill() throws IOException {
ensureOpen();
int n = in.read(buf, 0, buf.length);
/* A. Ellerton: remove the failure
if (len == -1) {
throw new EOFException("Unexpected end of ZLIB input stream");
}
inf.setInput(buf, 0, len);
*/
if (n > 0) {
len = n;
inf.setInput(buf, 0, len);
}
return n;
}
/**
* Tests if this input stream supports the <code>mark</code> and
* <code>reset</code> methods. The <code>markSupported</code>
* method of <code>InflaterInputStream</code> returns
* <code>false</code>.
*
* @return a <code>boolean</code> indicating if this stream type supports
* the <code>mark</code> and <code>reset</code> methods.
* @see InputStream#mark(int)
* @see InputStream#reset()
*/
public boolean markSupported() {
return false;
}
/**
* Marks the current position in this input stream.
*
* <p> The <code>mark</code> method of <code>InflaterInputStream</code>
* does nothing.
*
* @param readlimit the maximum limit of bytes that can be read before
* the mark position becomes invalid.
* @see InputStream#reset()
*/
public synchronized void mark(int readlimit) {
}
/**
* Repositions this stream to the position at the time the
* <code>mark</code> method was last called on this input stream.
*
* <p> The method <code>reset</code> for class
* <code>InflaterInputStream</code> does nothing except throw an
* <code>IOException</code>.
*
* @throws IOException if this method is invoked.
* @see InputStream#mark(int)
* @see IOException
*/
public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
}

View File

@@ -0,0 +1,62 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import org.jackhuang.hmcl.ui.image.apng.chunks.*;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.util.ArrayList;
import java.util.List;
/**
* A PngContainer represents the parsed content of a PNG file.
* <p>
* The original idea was that all implementations can use this as a "container"
* for representing the data, but I think it is too generic to be useful.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngContainer {
public List<PngChunkMap> chunks = new ArrayList<>(4);
public PngHeader header;
public PngGamma gamma;
public PngPalette palette;
//PngTransparency transarency;
//PngBackground background;
public PngAnimationControl animationControl;
public List<PngFrameControl> animationFrames;
public PngFrameControl currentFrame;
public boolean hasDefaultImage = false;
public PngHeader getHeader() {
return header;
}
public PngGamma getGamma() {
return gamma;
}
public boolean isAnimated() {
return animationControl != null;// && animationControl.numFrames > 1;
}
/* TODO need?
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<PngFrameControl> getAnimationFrames() {
return animationFrames;
}
public PngFrameControl getCurrentAnimationFrame() throws PngException {
if (animationFrames.isEmpty()) {
throw new PngIntegrityException("No animation frames yet");
}
return animationFrames.get(animationFrames.size()-1);
}
*/
}

View File

@@ -0,0 +1,21 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
/**
* Returns the PngContainer built by the PngContainerProcessor.
* WARNING: this may be removed.
*/
public class PngContainerBuilder extends PngContainerProcessor<PngContainer> {
// @Override
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
// return type;
// }
@Override
public PngContainer getResult() {
return getContainer();
}
}

View File

@@ -0,0 +1,94 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.chunks.*;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
/**
* TODO: need to keep this?
*/
public abstract class PngContainerProcessor<ResultT> implements PngChunkProcessor<ResultT> {
protected final PngContainer container;
public PngContainerProcessor() {
this.container = new PngContainer();
}
@Override
public void processHeader(PngHeader header) throws PngException {
container.header = header;
}
@Override
public void processGamma(PngGamma pngGamma) {
container.gamma = pngGamma;
}
@Override
public void processPalette(byte[] bytes, int position, int length) throws PngException {
container.palette = PngPalette.from(bytes, position, length);
}
@Override
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
// NOP
}
@Override
public void processAnimationControl(PngAnimationControl animationControl) {
container.animationControl = animationControl;
container.animationFrames = new ArrayList<PngFrameControl>(container.animationControl.numFrames);
}
@Override
public void processFrameControl(PngFrameControl pngFrameControl) throws PngException {
container.animationFrames.add(pngFrameControl);
container.currentFrame = pngFrameControl;
// imageDataProcessor = builder.makeFrameImageProcessor(container.currentFrame);
}
@Override
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (null != container.currentFrame) {
throw new IllegalStateException("Attempt to process main frame image data but an animation frame is in place");
}
container.hasDefaultImage = true;
// TODO: keep this?
processImageDataStream(inputStream);
}
@Override
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (null == container.currentFrame) {
throw new IllegalStateException("Attempt to process animation frame image data without a frame in place");
}
container.currentFrame.appendImageData(new PngChunkMap(code, position, length, 0));
// TODO: keep this?
processImageDataStream(inputStream);
}
protected void processImageDataStream(InputStream inputStream) throws IOException, PngException {
inputStream.skip(inputStream.available());
}
@Override
public void processChunkMapItem(PngChunkMap pngChunkMap) {
container.chunks.add(pngChunkMap);
}
public PngContainer getContainer() {
return container;
}
}

View File

@@ -0,0 +1,115 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image.internal;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.value.WritableValue;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.image.AnimationImage;
import java.lang.ref.WeakReference;
/**
* @author Glavo
*/
public final class AnimationImageImpl extends WritableImage implements AnimationImage {
private Animation animation;
private final int[][] frames;
private final int[] durations;
private final int cycleCount;
public AnimationImageImpl(int width, int height,
int[][] frames, int[] durations, int cycleCount) {
super(width, height);
if (frames.length != durations.length) {
throw new IllegalArgumentException("frames.length != durations.length");
}
this.frames = frames;
this.durations = durations;
this.cycleCount = cycleCount;
play();
}
public void play() {
if (animation == null) {
animation = new Animation(this);
animation.timeline.play();
}
}
private void updateImage(int frameIndex) {
final int width = (int) getWidth();
final int height = (int) getHeight();
final int[] frame = frames[frameIndex];
this.getPixelWriter().setPixels(0, 0,
width, height,
PixelFormat.getIntArgbInstance(),
frame, 0, width
);
}
private static final class Animation implements WritableValue<Integer> {
private final Timeline timeline = new Timeline();
private final WeakReference<AnimationImageImpl> imageRef;
private Integer value;
private Animation(AnimationImageImpl image) {
this.imageRef = new WeakReference<>(image);
timeline.setCycleCount(image.cycleCount);
int duration = 0;
for (int i = 0; i < image.frames.length; ++i) {
timeline.getKeyFrames().add(
new KeyFrame(Duration.millis(duration),
new KeyValue(this, i, Interpolator.DISCRETE)));
duration = duration + image.durations[i];
}
timeline.getKeyFrames().add(new KeyFrame(Duration.millis(duration)));
}
@Override
public Integer getValue() {
return value;
}
@Override
public void setValue(Integer value) {
this.value = value;
AnimationImageImpl image = imageRef.get();
if (image == null) {
timeline.stop();
return;
}
image.updateImage(value);
}
}
}

View File

@@ -63,7 +63,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -333,8 +332,8 @@ class ModListPageSkin extends SkinBase<ModListPage> {
if (StringUtils.isNotBlank(logoPath)) {
Path iconPath = fs.getPath(logoPath);
if (Files.exists(iconPath)) {
try (InputStream stream = Files.newInputStream(iconPath)) {
Image image = new Image(stream, 40, 40, true, true);
try {
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
} catch (Throwable e) {
@@ -367,11 +366,9 @@ class ModListPageSkin extends SkinBase<ModListPage> {
for (String path : defaultPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
try (InputStream stream = Files.newInputStream(iconPath)) {
Image image = new Image(stream, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
}
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
}
}
} catch (Exception e) {

View File

@@ -63,5 +63,10 @@
"title" : "Java Native Access",
"subtitle" : "Licensed under the LGPL 2.1 License or the Apache 2.0 License.",
"externalLink" : "https://github.com/java-native-access/jna"
},
{
"title" : "Java Animated PNG",
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
"externalLink" : "https://github.com/aellerton/japng"
}
]

View File

@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class ImageUtilsTest {
private static byte[] readHeaderBuffer(String fileName) {
try (var input = Files.newInputStream(Path.of("src/test/resources/image/" + fileName))) {
return input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Test
public void testIsApng() {
assertTrue(ImageUtils.isApng(readHeaderBuffer("16x16.apng")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16.png")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-lossless.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-lossy.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
}
@Test
public void testIsWebP() {
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.apng")));
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.png")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-lossless.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-lossy.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
}
}

View File

@@ -0,0 +1,28 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import javafx.application.Application;
import javafx.stage.Stage;
public class ImageViewTest extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -76,6 +76,21 @@ public final class IOUtils {
return new String(readFully(stream), charset);
}
public static void skipNBytes(InputStream input, long n) throws IOException {
while (n > 0) {
long ns = input.skip(n);
if (ns > 0 && ns <= n)
n -= ns;
else if (ns == 0) {
if (input.read() == -1)
throw new EOFException();
n--;
} else {
throw new IOException("Unexpected skip bytes. Expected: " + n + ", Actual: " + ns);
}
}
}
public static void copyTo(InputStream src, OutputStream dest, byte[] buf) throws IOException {
while (true) {
int len = src.read(buf);