From 3118c87c65c526ca16ea8cdd793c7ee85efd230c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 6 Aug 2025 16:10:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E6=80=A7=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20APNG=20=E5=9B=BE=E7=89=87=20(#4205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCL/build.gradle.kts | 7 +- .../java/org/jackhuang/hmcl/ui/FXUtils.java | 128 ++--- .../org/jackhuang/hmcl/ui/HTMLRenderer.java | 8 +- .../hmcl/ui/image/AnimationImage.java | 24 + .../jackhuang/hmcl/ui/image/ImageLoader.java | 28 ++ .../jackhuang/hmcl/ui/image/ImageUtils.java | 411 ++++++++++++++++ .../org/jackhuang/hmcl/ui/image/apng/Png.java | 57 +++ .../hmcl/ui/image/apng/PngAnimationType.java | 19 + .../hmcl/ui/image/apng/PngChunkCode.java | 123 +++++ .../hmcl/ui/image/apng/PngColourType.java | 61 +++ .../hmcl/ui/image/apng/PngConstants.java | 120 +++++ .../hmcl/ui/image/apng/PngFilter.java | 125 +++++ .../hmcl/ui/image/apng/PngScanlineBuffer.java | 166 +++++++ .../image/apng/argb8888/Argb8888Bitmap.java | 48 ++ .../apng/argb8888/Argb8888BitmapSequence.java | 72 +++ .../Argb8888BitmapSequenceDirector.java | 82 ++++ .../image/apng/argb8888/Argb8888Director.java | 47 ++ .../image/apng/argb8888/Argb8888Palette.java | 117 +++++ .../apng/argb8888/Argb8888Processor.java | 173 +++++++ .../apng/argb8888/Argb8888Processors.java | 462 ++++++++++++++++++ .../argb8888/Argb8888ScanlineProcessor.java | 84 ++++ .../apng/argb8888/BasicArgb8888Director.java | 45 ++ .../DefaultImageArgb8888Director.java | 65 +++ .../apng/chunks/PngAnimationControl.java | 30 ++ .../ui/image/apng/chunks/PngFrameControl.java | 137 ++++++ .../hmcl/ui/image/apng/chunks/PngGamma.java | 22 + .../hmcl/ui/image/apng/chunks/PngHeader.java | 224 +++++++++ .../hmcl/ui/image/apng/chunks/PngPalette.java | 61 +++ .../ui/image/apng/error/PngException.java | 21 + .../image/apng/error/PngFeatureException.java | 17 + .../apng/error/PngIntegrityException.java | 19 + .../hmcl/ui/image/apng/map/PngChunkMap.java | 52 ++ .../hmcl/ui/image/apng/map/PngMap.java | 17 + .../hmcl/ui/image/apng/map/PngMapReader.java | 50 ++ .../hmcl/ui/image/apng/package-info.java | 4 + .../apng/reader/BasicScanlineProcessor.java | 65 +++ .../apng/reader/DefaultPngChunkReader.java | 303 ++++++++++++ .../ui/image/apng/reader/PngAtOnceSource.java | 109 +++++ .../image/apng/reader/PngChunkProcessor.java | 137 ++++++ .../ui/image/apng/reader/PngChunkReader.java | 134 +++++ .../ui/image/apng/reader/PngReadHelper.java | 112 +++++ .../hmcl/ui/image/apng/reader/PngReader.java | 20 + .../apng/reader/PngScanlineProcessor.java | 69 +++ .../hmcl/ui/image/apng/reader/PngSource.java | 43 ++ .../ui/image/apng/reader/PngStreamSource.java | 91 ++++ .../ui/image/apng/util/InputStreamSlice.java | 95 ++++ .../apng/util/PartialInflaterInputStream.java | 308 ++++++++++++ .../hmcl/ui/image/apng/util/PngContainer.java | 62 +++ .../image/apng/util/PngContainerBuilder.java | 21 + .../apng/util/PngContainerProcessor.java | 94 ++++ .../ui/image/internal/AnimationImageImpl.java | 115 +++++ .../hmcl/ui/versions/ModListPageSkin.java | 13 +- .../src/main/resources/assets/about/deps.json | 5 + .../hmcl/ui/image/ImageUtilsTest.java | 60 +++ .../hmcl/ui/image/ImageViewTest.java | 28 ++ .../image/16x16-animation-lossless.webp | Bin 0 -> 7404 bytes .../image/16x16-animation-lossy.webp | Bin 0 -> 5112 bytes .../test/resources/image/16x16-lossless.webp | Bin 0 -> 718 bytes .../src/test/resources/image/16x16-lossy.webp | Bin 0 -> 360 bytes HMCL/src/test/resources/image/16x16.apng | Bin 0 -> 22613 bytes HMCL/src/test/resources/image/16x16.png | Bin 0 -> 1039 bytes .../org/jackhuang/hmcl/util/io/IOUtils.java | 15 + 62 files changed, 4924 insertions(+), 101 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/AnimationImage.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageLoader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/Png.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngAnimationType.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngChunkCode.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngColourType.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngConstants.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngFilter.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/PngScanlineBuffer.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Bitmap.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequence.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888BitmapSequenceDirector.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Director.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Palette.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888Processors.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/Argb8888ScanlineProcessor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/BasicArgb8888Director.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/argb8888/DefaultImageArgb8888Director.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngAnimationControl.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngFrameControl.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngGamma.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngHeader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/chunks/PngPalette.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngException.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngFeatureException.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/error/PngIntegrityException.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngChunkMap.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMap.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/map/PngMapReader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/package-info.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/BasicScanlineProcessor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/DefaultPngChunkReader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngAtOnceSource.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkProcessor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngChunkReader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReadHelper.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngReader.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngScanlineProcessor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngSource.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/reader/PngStreamSource.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/InputStreamSlice.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PartialInflaterInputStream.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainer.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerBuilder.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/apng/util/PngContainerProcessor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/image/internal/AnimationImageImpl.java create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageViewTest.java create mode 100644 HMCL/src/test/resources/image/16x16-animation-lossless.webp create mode 100644 HMCL/src/test/resources/image/16x16-animation-lossy.webp create mode 100644 HMCL/src/test/resources/image/16x16-lossless.webp create mode 100644 HMCL/src/test/resources/image/16x16-lossy.webp create mode 100644 HMCL/src/test/resources/image/16x16.apng create mode 100644 HMCL/src/test/resources/image/16x16.png 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 0000000000000000000000000000000000000000..2ab37f99f29a3459f56c07b96acd77fb1f18fa70 GIT binary patch literal 7404 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b5995e8c1ec2bdec7363f47c5f2b4da253c8b0c GIT binary patch literal 5112 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..167c6da5c874eec746a239bf1fd8968f0a4d0ba7 GIT binary patch literal 718 zcmV;<0x|tkNk&G-0ssJ4MM6+kP&iDv0ssIn55NNu4+f!;Bu7$Kb>F=={P8@Ghq1ov z4GBmB*tTr|kj&o6|KHYAuY0Pd05_6s*UIePCqEioJmMxP62W`=orM|*00QOS-Q5MM zvjmMH7>F@G(`l~BrMi341ONp80TDm|&;$ek5kS4x1aZrDGP-a0GrM4*(+Xa|*{xM; znIlJ;W(l9=+>;e)3ZW1sT77=yp+o6Cc zVhH{{pisi9P>Sxr{?8=f6h}Ck74rCv+q%Q1^va|V%qf-}#a}cA&rxDv#Bq(o{pD5u zM(tO<+x7lZX}o{AuLceiq|%_7A__!Dhg#hDMzpw=Z<~9e^|Jr48s=b#CgS7#Mik4S zJZ&eP9fv*dW?#R**3XMjAg1LUhjZ95ZWsa5F*9PMV`j_}V2AN+s}fPRk^q%bVejMJ z`t*JM{ruNFytsZk*Vp8%|Lf%5bjJ2ow!d}bT<-K#JzQTT(ow3+P!q9$OP1d8e~Pu0 zS(nrKetvZEPyCDkVcWLjspx06y=>dIZM$U~%N%l3uC;6%zio)<{{(oF5{z5^ zH54Qtq*Qje z+^J(ag<6t#1hGbeL#($&E1q6$H$BA z?CtLWyxb#p^jKGDY2rs*^*0&-|4XYq>-Z9BgP9~SNcbr(39ox3gNZ}|gMt8k0J9rw AHUIzs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fc496537e5cca9215331a6e398076085caa84597 GIT binary patch literal 360 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 literal 0 HcmV?d00001 diff --git a/HMCL/src/test/resources/image/16x16.png b/HMCL/src/test/resources/image/16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..a8aeffbd2dcabda6f19c04d6d67207449e7356d2 GIT binary patch literal 1039 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=IjczVPIhD2=EDU1u9UIU|?iqFjZq^VP@c9V`5}rU}a`t zVP*nK@p3ZAOK@l@@(J+(MSubfvLfsu7V=erhLwSanQj^eihMu~{9Ft&qHMkqi zGg9qSoYhpMxJ39_KpGf;QtZBVN|CPW>TdIRhYAGows7Uk2x~ivoYw0No zgN$Qf;NxP|QxcLEX1CT6lN4f?7U2d8vM@6mtBI!h=oE(N1I?EZ;uPWMkPzY$7vz)| z=LITAchydD(VCa(GCjq)C&sQj#%@ZIpIJ8IFbVb|s#M*U6+4jZR zFUfOF^i&VEQk)J{kmEKh&1Gt`)9iGYS!oW*PU?|P%GJ@vvCeAUv6f~2`VC}WCJFB9ewK21p1R3y8u9L`HoBq~+M?0UO4S6Xu;U4Cu23mf0+S!4IA@1hpI&yWHK^3X~+R75{wgz?a77gV^0q%B_yW4wOn?p44AfkW-SMlarpBo*38L+L#((-rLq(l#{i1*0lcimV)ez_~^)~y-*YT9Bge0voojkc9j?AW%=vc z8mY=ji~HGXwpHbt8tN-b3AkA5SsH1H2yp4C%X`=w6ou-m%W!*{Nw-yI#fJJ=Xo@C# zX+=4!8mS1lnMh^$YEG>Pjq-OiP!_6;GHOjW53*BrG?f1T|G$3lwPs+Hgp>sNfzmMq zaJX!~Q7F#kvh?k*6Y1i-{u@94Ih!lV$U5c6--Gg;JD=eeHPBGTByV?@>k~Fb*#kN3C7!;n z>@PW3d2~$YtmORw6q@Gg;usG|wsN;voPh;)N`N*im%v7kve o8_J9MR?qjC#3TRUvGqy&Maoj^4cBi_0J@OD)78&qol`;+07aiWL;wH) literal 0 HcmV?d00001 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);