diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 8f35d5c2b..762abe702 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -95,6 +95,11 @@ tasks.withType { 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/**") } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index 0a9b79781..ad97e738d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -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 IMAGE_EXTENSIONS = Lang.immutableListOf( - "png", "jpg", "jpeg", "bmp", "gif", "webp" + "png", "jpg", "jpeg", "bmp", "gif", "webp", "apng" ); private static final Map 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 getRemoteImageTask(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + public static Task 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 newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + public static ObservableValue newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { var image = new SimpleObjectProperty(); getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth) .whenComplete(Schedulers.javafx(), (result, exception) -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java index 08c33aef1..a7dcc2643 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -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) { } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/AnimationImage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/AnimationImage.java new file mode 100644 index 000000000..e22deccf7 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/AnimationImage.java @@ -0,0 +1,24 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +package org.jackhuang.hmcl.ui.image; + +/** + * @author Glavo + */ +public interface AnimationImage { +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageLoader.java new file mode 100644 index 000000000..a7e87271b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageLoader.java @@ -0,0 +1,28 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java new file mode 100644 index 000000000..261ab519c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java @@ -0,0 +1,411 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 EXT_TO_LOADER = Map.of( + "webp", WEBP, + "apng", APNG + ); + + public static final Map CONTENT_TYPE_TO_LOADER = Map.of( + "image/webp", WEBP, + "image/apng", APNG + ); + + public static final Set DEFAULT_EXTS = Set.of( + "jpg", "jpeg", "bmp", "gif" + ); + + public static final Set 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(?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 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() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/Png.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/Png.java new file mode 100644 index 000000000..dfac71fa9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/Png.java @@ -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 read(InputStream is, PngReader reader) throws PngException { + return PngReadHelper.read(is, reader); + } + + public static ResultT read(InputStream is, PngChunkProcessor processor) throws PngException { + return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor)); + } + + public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException { + Argb8888Processor processor = new Argb8888Processor<>(new DefaultImageArgb8888Director()); + return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor)); + } + + public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException { + Argb8888Processor processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector()); + return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor)); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngAnimationType.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngAnimationType.java new file mode 100644 index 000000000..d21bb4fe2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngAnimationType.java @@ -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. + *

+ * 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; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngChunkCode.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngChunkCode.java new file mode 100644 index 000000000..c7317ee38 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngChunkCode.java @@ -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. + *

+ * 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 + * + *

+     *     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)
+     * 
+ * + * @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)); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngColourType.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngColourType.java new file mode 100644 index 000000000..d7d0811ce --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngColourType.java @@ -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)); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngConstants.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngConstants.java new file mode 100644 index 000000000..bf8a14724 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngConstants.java @@ -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"); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngFilter.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngFilter.java new file mode 100644 index 000000000..45bddb636 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngFilter.java @@ -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>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> 1)) & 0xff); + bi++; + } + + for (int i=pixelStart+filterUnit; i> 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= 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++; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngScanlineBuffer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngScanlineBuffer.java new file mode 100644 index 000000000..c285f11b4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngScanlineBuffer.java @@ -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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Bitmap.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Bitmap.java new file mode 100644 index 000000000..096f376dc --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Bitmap.java @@ -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); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequence.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequence.java new file mode 100644 index 000000000..b19eff099 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequence.java @@ -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. + *

+ * Note that instances of this class will hold an individual bitmap for every frame + * and does not 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 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 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; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequenceDirector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequenceDirector.java new file mode 100644 index 000000000..af2aa0e37 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequenceDirector.java @@ -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 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Director.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Director.java new file mode 100644 index 000000000..a7308f410 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Director.java @@ -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. + *

+ * TODO: not sure if this will stay in this form. Needs refinement. + */ +public interface Argb8888Director { + + 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(); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Palette.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Palette.java new file mode 100644 index 000000000..7f9f495b2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Palette.java @@ -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. + *

+ * 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)); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processor.java new file mode 100644 index 000000000..1980e904c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processor.java @@ -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 implements PngChunkProcessor { //PngChunkProcessor { + + protected PngHeader header = null; + protected PngScanlineBuffer scanlineReader = null; + protected Argb8888Director builder = null; + protected Argb8888ScanlineProcessor scanlineProcessor = null; + + + public Argb8888Processor(Argb8888Director 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(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processors.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processors.java new file mode 100644 index 000000000..adc464aab --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processors.java @@ -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. + *

+ * 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() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888ScanlineProcessor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888ScanlineProcessor.java new file mode 100644 index 000000000..42063b38d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888ScanlineProcessor.java @@ -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. + *

+ * 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); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/BasicArgb8888Director.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/BasicArgb8888Director.java new file mode 100644 index 000000000..15c54ebd2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/BasicArgb8888Director.java @@ -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 implements Argb8888Director { + 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); + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/DefaultImageArgb8888Director.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/DefaultImageArgb8888Director.java new file mode 100644 index 000000000..fe4bd4adc --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/DefaultImageArgb8888Director.java @@ -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 { + + 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngAnimationControl.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngAnimationControl.java new file mode 100644 index 000000000..b978c76f7 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngAnimationControl.java @@ -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 + + '}'; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngFrameControl.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngFrameControl.java new file mode 100644 index 000000000..0c57f5651 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngFrameControl.java @@ -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. + *

+ * See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk + *

+ *     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
+ * 
+ *

+ * Delay denominator: from spec, "if denominator is zero it should be treated as 100ths of second". + *

+ * 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
+ * 
+ */ +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 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 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngGamma.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngGamma.java new file mode 100644 index 000000000..ccad1dc55 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngGamma.java @@ -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()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngHeader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngHeader.java new file mode 100644 index 000000000..0a6e31c75 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngHeader.java @@ -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 channel of a given pixel. + * A better name might be "bitsPerPixelChannel" but the name "bitDepth" is used + * throughout the PNG specification. + *

+ * 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. + *

+ * A truecolour with alpha 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. + *

+ * A truecolour with alpha image with bitDepth of 16 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. + *

+ * A greyscale image (no alpha) with bitDepth of 16 has only a grey channel for + * each pixel, so the bitsPerPixel will also be 16. + *

+ * But a greyscale image with alpha 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. + *

+ * As for palette-based images... + *

    + *
  • A monochrome image or image with 2 colour palette has bitDepth=1.
  • + *
  • An image with 4 colour palette has bitDepth=2.
  • + *
  • An image with 8 colour palette has bitDepth=3.
  • + *
  • An image with 16 colour palette has bitDepth=4.
  • + *
  • A greyscale image with 16 levels of gray and an alpha channel + * has bitDepth=4 and bitsPerPixel=8 because the gray and the alpha channel + * each have 4 bits.
  • + *
+ * + * @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); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngPalette.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngPalette.java new file mode 100644 index 000000000..57423fa12 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngPalette.java @@ -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. + *

+ * 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; + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngException.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngException.java new file mode 100644 index 000000000..13fdff813 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngException.java @@ -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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngFeatureException.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngFeatureException.java new file mode 100644 index 000000000..923c50ab8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngFeatureException.java @@ -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); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngIntegrityException.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngIntegrityException.java new file mode 100644 index 000000000..96501839c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngIntegrityException.java @@ -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. + *

+ * 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); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngChunkMap.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngChunkMap.java new file mode 100644 index 000000000..036b87786 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngChunkMap.java @@ -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. + *

+ * WARNING: not sure if this API will remain. + *

+ */ +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 + + '}'; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMap.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMap.java new file mode 100644 index 000000000..ead524b3a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMap.java @@ -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. + *

+ * WARNING: not sure if this API will remain. + *

+ */ +public class PngMap { + public String source; + public List chunks; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMapReader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMapReader.java new file mode 100644 index 000000000..cda8f0c99 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMapReader.java @@ -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. + *

+ * WARNING: not sure if this API will remain. + *

+ */ +public class PngMapReader implements PngReader { + 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/package-info.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/package-info.java new file mode 100644 index 000000000..844ab03a9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/package-info.java @@ -0,0 +1,4 @@ +/** + * @see japng + */ +package org.jackhuang.hmcl.ui.image.apng; \ No newline at end of file diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/BasicScanlineProcessor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/BasicScanlineProcessor.java new file mode 100644 index 000000000..f04c1711e --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/BasicScanlineProcessor.java @@ -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 + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/DefaultPngChunkReader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/DefaultPngChunkReader.java new file mode 100644 index 000000000..2ace0558e --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/DefaultPngChunkReader.java @@ -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. + *

+ * Note that any chunk types not recognised can be processed in the readOtherChunk() + * method. + */ +public class DefaultPngChunkReader implements PngChunkReader { + protected PngChunkProcessor 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 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngAtOnceSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngAtOnceSource.java new file mode 100644 index 000000000..61eee29e2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngAtOnceSource.java @@ -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. + *

+ * 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. + *

+ * WARNING: I'm not sure I'll keep this. I may remove it and do everything with + * a BufferedInputStream + DataInputStream. + *

+ */ +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()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkProcessor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkProcessor.java new file mode 100644 index 000000000..59c628791 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkProcessor.java @@ -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 processing 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 { + + /** + * 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. + *

+ * 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. + *

+ * 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. + *

+ * + * @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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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(); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkReader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkReader.java new file mode 100644 index 000000000..e3715f724 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkReader.java @@ -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 extends PngReader { + @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. + *

+ * From http://www.w3.org/TR/PNG/#11tRNS: + *

+ * 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: + *

+ * Colour type 0: + *

+ * Grey sample value 2 bytes + *

+ * Colour type 2: + *

+ * Red sample value 2 bytes + * Blue sample value 2 bytes + * Green sample value 2 bytes + *

+ * Colour type 3: + *

+ * Alpha for palette index 0 1 byte + * Alpha for palette index 1 1 byte + * ...etc... 1 byte + *

+ * 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. + *

+ * From http://www.w3.org/TR/PNG/#11bKGD: + *

+ * 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: + *

+ * 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 + *

+ * For colour type 3 (indexed-colour), the value is the palette index of the colour to be used as background. + *

+ * 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. + *

+ * The default implementation skips the data, deferring to the finishedChunks() method + * to process the data. Key reasons to do this: + *

    + *
  • 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.
  • + *
  • This might be an APNG file and the IDAT chunk(s) are to be skipped.
  • + *
+ * + * @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. + *

+ * 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; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReadHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReadHelper.java new file mode 100644 index 000000000..29168f0b1 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReadHelper.java @@ -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. + *

+ * If the stream ends before the signature is read an EOFExceptoin is thrown. + *

+ * Note that no temporary buffer is allocated. + *

+ * + * @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 result of the processing + * @return result of the processing of the InputStream. + * @throws PngException + */ + public static ResultT read(InputStream is, PngReader 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. + *

+ * 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() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReader.java new file mode 100644 index 000000000..600419db2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReader.java @@ -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 { + boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException; + + void finishedChunks(PngSource source) throws PngException, IOException; + + ResultT getResult(); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngScanlineProcessor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngScanlineProcessor.java new file mode 100644 index 000000000..9289fe711 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngScanlineProcessor.java @@ -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. + *

+ * 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. + *

+ * 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); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngSource.java new file mode 100644 index 000000000..a1bde97a2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngSource.java @@ -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 + *

+ * 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; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngStreamSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngStreamSource.java new file mode 100644 index 000000000..0c3592725 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngStreamSource.java @@ -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); + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/InputStreamSlice.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/InputStreamSlice.java new file mode 100644 index 000000000..d506f5e78 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/InputStreamSlice.java @@ -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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PartialInflaterInputStream.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PartialInflaterInputStream.java new file mode 100644 index 000000000..d43e46d1a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PartialInflaterInputStream.java @@ -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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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." + * + * + * @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 len is not + * zero, the method will block until some input can be decompressed; otherwise, + * no bytes are read and 0 is returned. + * + * @param b the buffer into which the data is read + * @param off the start offset in the destination array b + * @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 b is null. + * @throws IndexOutOfBoundsException If off is negative, + * len is negative, or len is greater than + * b.length - off + * @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. + *

+ * 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 mark and + * reset methods. The markSupported + * method of InflaterInputStream returns + * false. + * + * @return a boolean indicating if this stream type supports + * the mark and reset methods. + * @see InputStream#mark(int) + * @see InputStream#reset() + */ + public boolean markSupported() { + return false; + } + + /** + * Marks the current position in this input stream. + * + *

The mark method of InflaterInputStream + * 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 + * mark method was last called on this input stream. + * + *

The method reset for class + * InflaterInputStream does nothing except throw an + * IOException. + * + * @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"); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainer.java new file mode 100644 index 000000000..8c8e63e37 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainer.java @@ -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. + *

+ * 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. + *

+ * WARNING: not sure if this API will remain. + *

+ */ +public class PngContainer { + public List chunks = new ArrayList<>(4); + public PngHeader header; + public PngGamma gamma; + public PngPalette palette; + //PngTransparency transarency; + //PngBackground background; + + public PngAnimationControl animationControl; + public List 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 getAnimationFrames() { + return animationFrames; + } + + public PngFrameControl getCurrentAnimationFrame() throws PngException { + if (animationFrames.isEmpty()) { + throw new PngIntegrityException("No animation frames yet"); + } + return animationFrames.get(animationFrames.size()-1); + } + */ +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerBuilder.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerBuilder.java new file mode 100644 index 000000000..2e99be40c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerBuilder.java @@ -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 { + +// @Override +// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException { +// return type; +// } + + @Override + public PngContainer getResult() { + return getContainer(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerProcessor.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerProcessor.java new file mode 100644 index 000000000..2f4afa6f2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerProcessor.java @@ -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 implements PngChunkProcessor { + 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(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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/internal/AnimationImageImpl.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/internal/AnimationImageImpl.java new file mode 100644 index 000000000..260c59d0a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/internal/AnimationImageImpl.java @@ -0,0 +1,115 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 { + private final Timeline timeline = new Timeline(); + private final WeakReference 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); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index ad493ea88..e664d43e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -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 { 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 { 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) { diff --git a/HMCL/src/main/resources/assets/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index bdfd53199..7daa69359 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -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" } ] \ No newline at end of file diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java new file mode 100644 index 000000000..53c2ae0f9 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java @@ -0,0 +1,60 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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"))); + } + +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageViewTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageViewTest.java new file mode 100644 index 000000000..e470defe9 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageViewTest.java @@ -0,0 +1,28 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 { + + } +} diff --git a/HMCL/src/test/resources/image/16x16-animation-lossless.webp b/HMCL/src/test/resources/image/16x16-animation-lossless.webp new file mode 100644 index 000000000..2ab37f99f Binary files /dev/null and b/HMCL/src/test/resources/image/16x16-animation-lossless.webp differ diff --git a/HMCL/src/test/resources/image/16x16-animation-lossy.webp b/HMCL/src/test/resources/image/16x16-animation-lossy.webp new file mode 100644 index 000000000..1b5995e8c Binary files /dev/null and b/HMCL/src/test/resources/image/16x16-animation-lossy.webp differ diff --git a/HMCL/src/test/resources/image/16x16-lossless.webp b/HMCL/src/test/resources/image/16x16-lossless.webp new file mode 100644 index 000000000..167c6da5c Binary files /dev/null and b/HMCL/src/test/resources/image/16x16-lossless.webp differ diff --git a/HMCL/src/test/resources/image/16x16-lossy.webp b/HMCL/src/test/resources/image/16x16-lossy.webp new file mode 100644 index 000000000..fc496537e Binary files /dev/null and b/HMCL/src/test/resources/image/16x16-lossy.webp differ diff --git a/HMCL/src/test/resources/image/16x16.apng b/HMCL/src/test/resources/image/16x16.apng new file mode 100644 index 000000000..a55a089f8 Binary files /dev/null and b/HMCL/src/test/resources/image/16x16.apng differ diff --git a/HMCL/src/test/resources/image/16x16.png b/HMCL/src/test/resources/image/16x16.png new file mode 100644 index 000000000..a8aeffbd2 Binary files /dev/null and b/HMCL/src/test/resources/image/16x16.png differ diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/IOUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/IOUtils.java index 6e22794b6..5dc080e79 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/IOUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/IOUtils.java @@ -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);