Compare commits

...
32 Commits
Author SHA1 Message Date
Glavo 3cdddfa86d 在实例名称中包含非字母、数字、减号和下划线字符时弹出警告 (#4222) 2025-08-09 21:29:41 +08:00
Glavo b1d8edc72a FetchTask 启用透明 GZip 压缩 (#4219) 2025-08-08 16:24:48 +08:00
Glavo bbc546e2a0 创建 ByteArray 辅助类 (#4214) 2025-08-07 16:14:10 +08:00
Liu Wenyuan f621776cdd 修复 APNG Truecolour16Alpha 处理器 clone 方法返回类型错误的问题 (#4210) 2025-08-07 15:04:57 +08:00
ENC_Euphony 7ba9d9eadd 智能选择 popup 弹出位置 (#4203) 2025-08-07 15:01:18 +08:00
ZkiteflyandGlavo ac4c3a7501 模组管理支持搜索根据更多数据搜索内容 (#3230)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-06 21:02:36 +08:00
Glavo f95ebe2224 优化 GetTask 中 ByteArrayOutputStream 的初始大小 (#4209) 2025-08-06 20:43:51 +08:00
辞庐and3gf8jv4dv 7ee2c51b81 将 Apple M1 平台 改为 Apple Silicon 平台 (#4199)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-06 20:40:24 +08:00
Glavo 192c90816b 修复 #3716 与 main 的冲突 (#4208) 2025-08-06 20:24:46 +08:00
1a772e8c65 添加 Modrinth 整合包导出 (#3716)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 20:11:39 +08:00
3gf8jv4dv 95bed4712c chore(locale): port and fix translation in native directory path preview (#4197) 2025-08-06 16:51:29 +08:00
Glavo 2c6ceb3cb0 优化 data URI 支持 (#4206) 2025-08-06 16:48:37 +08:00
Glavo 3118c87c65 实验性支持 APNG 图片 (#4205) 2025-08-06 16:10:45 +08:00
ENC_Euphony f945a85441 Fix: 修复一些 typo (#4202) 2025-08-05 22:51:06 +08:00
辞庐 72ffbb4176 为弹窗文本添加平滑滚动 (#4201) 2025-08-05 22:50:47 +08:00
辞庐 77dc5dbe06 修复整合包格式不匹配提示未被正确格式化的问题 (#4176) 2025-08-04 20:32:42 +08:00
Glavo 0a1d6be7b7 优化 ModTranslations::loadFromResource (#4178) 2025-08-04 20:31:29 +08:00
Wulian233 bb3d03fc72 修复一些代码中的 typo (#4190) 2025-08-04 20:30:36 +08:00
Glavo a0ddaf6766 GetTask 应当遵循 Content-Type 中的字符集设置 (#4183) 2025-08-04 20:28:46 +08:00
辞庐andGlavo 0bd2adc3c6 Fix #4187: 修复 LWJGL 路径显示错误的问题 (#4192)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 20:24:19 +08:00
Glavo 77c3ef6ced 修复未正确处理 EnumBackgroundImage.TRANSLUCENT 的问题 (#4195) 2025-08-04 20:18:29 +08:00
3gf8jv4dv a66ddb9a20 chore(locale): port character length limit translation to es and ru (#4191) 2025-08-04 20:17:23 +08:00
3gf8jv4dv eef365b37e chore(locale): port background option translation to es and ru (#4194) 2025-08-04 17:10:19 +08:00
3gf8jv4dv 5cab603b6e chore(locale): port font anti-aliasing option translation to es and ru (#4193) 2025-08-04 17:09:54 +08:00
神奇的赵二 (OrzMiku) 6c5d07adad fix(#4166): 新增内存显示条禁用样式 (#4185) 2025-08-04 17:08:19 +08:00
3gf8jv4dv 1935a7021d fix(locale): corrent some typos in zh-tw (#4189) 2025-08-04 16:46:11 +08:00
3gf8jv4dv 31c46c963d fix(locale): corrent residual jvm terms in chinese (#4188) 2025-08-04 16:09:21 +08:00
吞云(TuYw)andGlavo 4bc6ffa0f0 支持修改主页背景的不透明度 (#3205)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 16:05:47 +08:00
Glavo b62659de62 支持纯色背景 (#4184) 2025-08-04 15:17:46 +08:00
Glavo 9a27c27b9a 支持使用 data URI 作为背景图片源 (#4182) 2025-08-03 20:37:02 +08:00
Glavo 74c647cc97 统一转义 URI 中的特殊字符 (#4181) 2025-08-03 19:34:34 +08:00
Glavo e2df8e25b1 修复无法找到不包含 release 文件的 Java 的问题 (#4180) 2025-08-03 19:20:11 +08:00
150 changed files with 6887 additions and 478 deletions

No files matched your search

+6 -1
View File
@@ -95,6 +95,11 @@ tasks.withType<JavaCompile> {
targetCompatibility = "11"
}
tasks.checkstyleMain {
// Third-party code is not checked
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
@@ -116,7 +121,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*","freebsd-*", "linux-*", "darwin-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
@@ -267,7 +267,7 @@ public final class LauncherHelper {
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
URI uri = rce.getUri();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", uri);
else
@@ -298,7 +298,7 @@ public final class LauncherHelper {
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
int responseCode = rce.getResponseCode();
URI uri = rce.getUri();
String uri = rce.getUri();
if (responseCode == 404)
message = i18n("download.code.404", uri);
else
@@ -45,7 +45,6 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -110,7 +109,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(URI.create(texture.getUrl()), file).run();
new FileDownloadTask(texture.getUrl(), file).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -487,12 +487,15 @@ public final class JavaManager {
try {
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e2) {
e2.addSuppressed(e);
LOG.warning("Failed to lookup Java executable at " + executable, e2);
}
LOG.warning("Failed to read release file " + releaseFile, e);
}
}
if (info == null) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
}
@@ -346,7 +346,7 @@ public final class Accounts {
try {
server.fetchMetadataResponse();
} catch (IOException e) {
LOG.warning("Failed to fetch authlib-injector server metdata: " + server, e);
LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e);
}
});
}
@@ -29,6 +29,7 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.scene.paint.Paint;
import org.hildan.fxgson.creators.ObservableListCreator;
import org.hildan.fxgson.creators.ObservableMapCreator;
import org.hildan.fxgson.creators.ObservableSetCreator;
@@ -37,6 +38,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.util.gson.EnumOrdinalDeserializer;
import org.jackhuang.hmcl.util.gson.FileTypeAdapter;
import org.jackhuang.hmcl.util.gson.PaintAdapter;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
@@ -60,6 +62,7 @@ public final class Config implements Observable {
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
.registerTypeAdapter(Paint.class, new PaintAdapter())
.setPrettyPrinting()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
@@ -87,6 +90,12 @@ public final class Config implements Observable {
@SerializedName("bgurl")
private StringProperty backgroundImageUrl = new SimpleStringProperty();
@SerializedName("bgImageOpacity")
private IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
@SerializedName("bgpaint")
private ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
@SerializedName("commonDirType")
private ObjectProperty<EnumCommonDirectory> commonDirType = new SimpleObjectProperty<>(EnumCommonDirectory.DEFAULT);
@@ -172,7 +181,7 @@ public final class Config implements Observable {
private BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
@SerializedName("authlibInjectorServers")
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[] { server });
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
@SerializedName("addedLittleSkin")
private BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
@@ -274,6 +283,30 @@ public final class Config implements Observable {
this.backgroundImageUrl.set(backgroundImageUrl);
}
public Paint getBackgroundPaint() {
return backgroundPaint.get();
}
public ObjectProperty<Paint> backgroundPaintProperty() {
return backgroundPaint;
}
public void setBackgroundPaint(Paint backgroundPaint) {
this.backgroundPaint.set(backgroundPaint);
}
public int getBackgroundImageOpacity() {
return backgroundImageOpacity.get();
}
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
this.backgroundImageOpacity.set(backgroundImageOpacity);
}
public IntegerProperty backgroundImageOpacityProperty() {
return backgroundImageOpacity;
}
public EnumCommonDirectory getCommonDirType() {
return commonDirType.get();
}
@@ -22,5 +22,6 @@ public enum EnumBackgroundImage {
CUSTOM,
CLASSIC,
NETWORK,
TRANSLUCENT
TRANSLUCENT, // Deprecated
PAINT
}
@@ -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;
@@ -47,6 +46,7 @@ import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
@@ -59,6 +59,8 @@ 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.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -73,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;
@@ -167,7 +161,7 @@ public final class FXUtils {
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
public static final List<String> IMAGE_EXTENSIONS = Lang.immutableListOf(
"png", "jpg", "jpeg", "bmp", "gif", "webp"
"png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
@@ -744,6 +738,106 @@ public final class FXUtils {
property.removeListener(binding);
}
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<Paint>> propertyRef;
private final int hashCode;
private boolean updating = false;
private PaintBidirectionalBinding(ColorPicker colorPicker, Property<Paint> property) {
this.colorPickerRef = new WeakReference<>(colorPicker);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final ColorPicker colorPicker = colorPickerRef.get();
final Property<Paint> property = propertyRef.get();
if (colorPicker == null || property == null) {
if (colorPicker != null) {
colorPicker.valueProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
Paint newValue = property.getValue();
if (newValue instanceof Color)
colorPicker.setValue((Color) newValue);
else
colorPicker.setValue(null);
} else {
Paint newValue = colorPicker.getValue();
property.setValue(newValue);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return colorPickerRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof FXUtils.PaintBidirectionalBinding))
return false;
var that = (FXUtils.PaintBidirectionalBinding) o;
final ColorPicker colorPicker = this.colorPickerRef.get();
final Property<Paint> property = this.propertyRef.get();
final ColorPicker thatColorPicker = that.colorPickerRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null)
return false;
return colorPicker == thatColorPicker && property == thatProperty;
}
}
public static void bindPaint(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
if (property.getValue() instanceof Color)
colorPicker.setValue((Color) property.getValue());
else
colorPicker.setValue(null);
colorPicker.valueProperty().addListener(binding);
property.addListener(binding);
}
public static void unbindColorPicker(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
@@ -811,47 +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 {
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;
return loadImage(path, 0, 0, false, false);
}
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(URI uri) throws Exception {
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection) {
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
}
public static Image loadImage(String url) throws Exception {
URI uri = NetworkUtils.toURI(url);
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;
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection)
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
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);
}
}
@@ -895,45 +995,12 @@ public final class FXUtils {
}
}
public static Task<Image> getRemoteImageTask(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(URI.create(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;
}
});
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
}
public static ObservableValue<Image> newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
var image = new SimpleObjectProperty<Image>();
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.util.StringUtils;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
@@ -159,28 +160,19 @@ public final class HTMLRenderer {
private void appendImage(Node node) {
String src = node.absUrl("src");
URI imageUri = null;
try {
if (!src.isEmpty())
imageUri = URI.create(src);
} catch (Exception ignored) {
}
String alt = node.attr("alt");
if (imageUri != null) {
URI uri = URI.create(src);
if (StringUtils.isNotBlank(src)) {
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) {
}
@@ -191,7 +183,7 @@ public final class HTMLRenderer {
}
try {
Image image = FXUtils.getRemoteImageTask(uri.toString(), width, height, true, true)
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
@@ -207,7 +199,7 @@ public final class HTMLRenderer {
children.add(imageView);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + uri, e);
LOG.warning("Failed to load image: " + src, e);
}
}
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jackhuang.hmcl.util.platform.NativeUtils;
@@ -48,7 +49,6 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import java.net.URI;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Locale;
@@ -190,7 +190,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
item.titleProperty().bind(title);
String host = "";
try {
host = URI.create(server.getUrl()).getHost();
host = NetworkUtils.toURI(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
@@ -34,7 +34,6 @@ import org.jackhuang.hmcl.util.Lang;
import javax.net.ssl.SSLException;
import java.io.IOException;
import java.net.URI;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@@ -196,7 +195,8 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
lblServerName.setText(serverBeingAdded.getName());
lblServerUrl.setText(serverBeingAdded.getUrl());
lblServerWarning.setVisible("http".equals(URI.create(serverBeingAdded.getUrl()).getScheme()));
//noinspection HttpUrlsUsage
lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://"));
this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT);
} else {
@@ -108,6 +108,7 @@ public final class MessageDialogPane extends HBox {
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setPrefHeight(350);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
@@ -24,11 +24,13 @@ import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Paint;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
@@ -297,4 +299,35 @@ public final class MultiFileItem<T> extends VBox {
return pane;
}
}
public static final class PaintOption<T> extends Option<T> {
private final ColorPicker colorPicker = new ColorPicker();
public PaintOption(String title, T data) {
super(title, data);
}
public PaintOption<T> bindBidirectional(Property<Paint> property) {
FXUtils.bindPaint(colorPicker, property);
return this;
}
@Override
protected Node createItem(ToggleGroup group) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(3));
FXUtils.setLimitHeight(pane, 30);
left.setText(title);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
left.setToggleGroup(group);
left.setUserData(data);
pane.setLeft(left);
colorPicker.disableProperty().bind(left.selectedProperty().not());
BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT);
pane.setRight(colorPicker);
return pane;
}
}
}
@@ -21,10 +21,7 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -58,9 +55,9 @@ public class URLValidator extends ValidatorBase {
hasErrors.set(!nullable);
else {
try {
new URL(textField.getText()).toURI();
NetworkUtils.toURI(textField.getText());
hasErrors.set(false);
} catch (IOException | URISyntaxException e) {
} catch (IllegalArgumentException e) {
hasErrors.set(true);
}
}
@@ -31,12 +31,16 @@ import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
@@ -44,6 +48,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
@@ -55,26 +60,22 @@ import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.*;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DecoratorController {
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
@@ -124,18 +125,13 @@ public class DecoratorController {
// Setup background
decorator.setContentBackground(getBackground());
changeBackgroundListener = o -> {
final int currentCount = ++this.changeBackgroundCount;
CompletableFuture.supplyAsync(this::getBackground, Schedulers.io())
.thenAcceptAsync(background -> {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
}, Schedulers.javafx());
};
changeBackgroundListener = o -> updateBackground();
WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener);
config().backgroundImageTypeProperty().addListener(weakListener);
config().backgroundImageProperty().addListener(weakListener);
config().backgroundImageUrlProperty().addListener(weakListener);
config().backgroundPaintProperty().addListener(weakListener);
config().backgroundImageOpacityProperty().addListener(weakListener);
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
@@ -196,6 +192,20 @@ public class DecoratorController {
@SuppressWarnings("FieldCanBeLocal") // Strong reference
private final InvalidationListener changeBackgroundListener;
private void updateBackground() {
final int currentCount = ++this.changeBackgroundCount;
Task.supplyAsync(Schedulers.io(), this::getBackground)
.setName("Update background")
.whenComplete(Schedulers.javafx(), (background, exception) -> {
if (exception == null) {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
} else {
LOG.warning("Failed to update background", exception);
}
}).start();
}
private Background getBackground() {
EnumBackgroundImage imageType = config().getBackgroundImageType();
@@ -214,7 +224,7 @@ public class DecoratorController {
String backgroundImageUrl = config().getBackgroundImageUrl();
if (backgroundImageUrl != null) {
try {
image = FXUtils.loadImage(URI.create(backgroundImageUrl));
image = FXUtils.loadImage(backgroundImageUrl);
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -223,13 +233,60 @@ public class DecoratorController {
case CLASSIC:
image = newBuiltinImage("/assets/img/background-classic.jpg");
break;
case TRANSLUCENT:
case TRANSLUCENT: // Deprecated
return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY));
case PAINT:
Paint paint = config().getBackgroundPaint();
double opacity = Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.;
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null)
color = Color.WHITE; // Default to white if no color is set
if (opacity < 1.)
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity);
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
} else {
// TODO: Support opacity for non-color paints
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
}
}
if (image == null) {
image = loadDefaultBackgroundImage();
}
return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(800, 480, false, false, true, true)));
return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity());
}
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
} else {
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
PixelReader pixelReader = image.getPixelReader();
PixelWriter pixelWriter = tempImage.getPixelWriter();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
Color color = pixelReader.getColor(x, y);
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity / 100);
pixelWriter.setColor(x, y, newColor);
}
}
return new Background(new BackgroundImage(
tempImage,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
}
}
/**
@@ -55,7 +55,6 @@ import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
@@ -156,7 +155,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(URI.create(file.getFile().getUrl()), dest);
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
task.setName(file.getName());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
@@ -27,7 +27,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Map;
@@ -77,11 +76,24 @@ public class InstallersPage extends AbstractInstallersPage {
public void cleanup(Map<String, Object> settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
}
protected void onInstall() {
String name = txtName.getText();
// Check for non-ASCII characters.
if (!StringUtils.isASCII(name)) {
if (!checkName(name)) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
@@ -18,11 +18,11 @@
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.mod.UnsupportedModpackException;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.Profile;
@@ -102,7 +102,7 @@ public class ModpackInstallWizardProvider implements WizardProvider {
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
} catch (MismatchedModpackTypeException e) {
Controllers.dialog(i18n("modpack.mismatched_type"), i18n("message.error"), MessageType.ERROR);
Controllers.dialog(i18n("modpack.mismatched_type", e.getRequired(), e.getFound()), i18n("message.error"), MessageType.ERROR);
} catch (IOException e) {
Controllers.dialog(i18n("modpack.invalid"), i18n("message.error"), MessageType.ERROR);
}
@@ -41,7 +41,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
@@ -124,10 +123,9 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
private void onChooseRemoteFile() {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (urlString, resolve, reject) -> {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> {
try {
URI url = URI.create(urlString);
if (urlString.endsWith("server-manifest.json")) {
if (url.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
@@ -148,7 +146,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
resolve.run();
Controllers.taskDialog(
new FileDownloadTask(url, modpack, null)
new FileDownloadTask(url, modpack)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
resolve.run();
@@ -133,7 +133,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
int responseCode = rce.getResponseCode();
URI uri = rce.getUri();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", uri);
else
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.setting.Config;
import org.jackhuang.hmcl.setting.FontManager;
import org.jackhuang.hmcl.setting.Profile;
@@ -100,6 +101,9 @@ public final class ExportWizardProvider implements WizardProvider {
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
exportTask = exportAsServer(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH:
exportTask = exportAsModrinth(exportInfo, dest);
break;
default:
throw new IllegalStateException("Unrecognized modpack type " + modpackType);
}
@@ -233,6 +237,27 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, File modpackFile) {
return new Task<Void>() {
Task<?> dependency;
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
profile.getRepository(),
version,
exportInfo,
modpackFile
);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
@@ -53,6 +53,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.tryCast;
@@ -80,6 +81,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private final SimpleStringProperty launchArguments = new SimpleStringProperty("");
private final SimpleStringProperty javaArguments = new SimpleStringProperty("");
private final SimpleStringProperty mcbbsThreadId = new SimpleStringProperty("");
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
this.controller = controller;
@@ -102,7 +105,13 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private void onNext() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
if (controller.getSettings().get(MODPACK_TYPE) == ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH) {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
fileChooser.setInitialFileName(name.get() + ".mrpack");
} else {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
}
File file = fileChooser.showSaveDialog(Controllers.getStage());
if (file == null) {
controller.onEnd();
@@ -122,6 +131,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
exportInfo.setLaunchArguments(launchArguments.get());
exportInfo.setJavaArguments(javaArguments.get());
exportInfo.setAuthlibInjectorServer(authlibInjectorServer.get());
exportInfo.setNoCreateRemoteFiles(noCreateRemoteFiles.get());
exportInfo.setSkipCurseForgeRemoteFiles(skipCurseForgeRemoteFiles.get());
if (StringUtils.isNotBlank(mcbbsThreadId.get())) {
exportInfo.setOrigins(Collections.singletonList(new McbbsModpackManifest.Origin(
@@ -178,6 +189,10 @@ public final class ModpackInfoPage extends Control implements WizardPage {
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
borderPane.setTop(hyperlink);
} if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
borderPane.setTop(pane);
} else {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.warning"));
@@ -366,6 +381,32 @@ public final class ModpackInfoPage extends Control implements WizardPage {
button.setMaxHeight(16);
pane.setRight(button);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
BorderPane noCreateRemoteFiles = new BorderPane();
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
list.getContent().add(noCreateRemoteFiles);
JFXToggleButton noCreateRemoteFilesButton = new JFXToggleButton();
noCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
noCreateRemoteFilesButton.setSize(8);
noCreateRemoteFilesButton.setMinHeight(16);
noCreateRemoteFilesButton.setMaxHeight(16);
noCreateRemoteFiles.setRight(noCreateRemoteFilesButton);
}
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
BorderPane skipCurseForgeRemoteFiles = new BorderPane();
skipCurseForgeRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files")));
list.getContent().add(skipCurseForgeRemoteFiles);
JFXToggleButton skipCurseForgeRemoteFilesButton = new JFXToggleButton();
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
skipCurseForgeRemoteFilesButton.setSize(8);
skipCurseForgeRemoteFilesButton.setMinHeight(16);
skipCurseForgeRemoteFilesButton.setMaxHeight(16);
skipCurseForgeRemoteFiles.setRight(skipCurseForgeRemoteFilesButton);
}
}
{
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
@@ -56,7 +57,8 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
title,
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
);
}
@@ -100,4 +102,5 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_MULTIMC = "multimc";
public static final String MODPACK_TYPE_SERVER = "server";
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
}
@@ -0,0 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
/**
* @author Glavo
*/
public interface AnimationImage {
}
@@ -0,0 +1,28 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import javafx.scene.image.Image;
import java.io.InputStream;
public interface ImageLoader {
Image load(InputStream input,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception;
}
@@ -0,0 +1,411 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.Timeline;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import org.jackhuang.hmcl.ui.image.apng.Png;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888BitmapSequence;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
import org.jackhuang.hmcl.util.SwingFXUtils;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class ImageUtils {
// ImageLoaders
public static final ImageLoader DEFAULT = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
Image image = new Image(input,
requestedWidth, requestedHeight,
preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
};
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
};
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
if (!"true".equals(System.getProperty("hmcl.experimental.apng", "true")))
return DEFAULT.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
try {
var sequence = Png.readArgb8888BitmapSequence(input);
final int width = sequence.header.width;
final int height = sequence.header.height;
boolean doScale;
if (requestedWidth > 0 && requestedHeight > 0
&& (requestedWidth != width || requestedHeight != height)) {
doScale = true;
if (preserveRatio) {
double scaleX = (double) requestedWidth / width;
double scaleY = (double) requestedHeight / height;
double scale = Math.min(scaleX, scaleY);
requestedWidth = (int) (width * scale);
requestedHeight = (int) (height * scale);
}
} else {
doScale = false;
}
if (sequence.isAnimated()) {
try {
return toImage(sequence, doScale, requestedWidth, requestedHeight);
} catch (Throwable e) {
LOG.warning("Failed to load animated image", e);
}
}
Argb8888Bitmap defaultImage = sequence.defaultImage;
int targetWidth;
int targetHeight;
int[] pixels;
if (doScale) {
targetWidth = requestedWidth;
targetHeight = requestedHeight;
pixels = scale(defaultImage.array,
defaultImage.width, defaultImage.height,
targetWidth, targetHeight);
} else {
targetWidth = defaultImage.width;
targetHeight = defaultImage.height;
pixels = defaultImage.array;
}
WritableImage image = new WritableImage(targetWidth, targetHeight);
image.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight,
PixelFormat.getIntArgbInstance(), pixels,
0, targetWidth);
return image;
} catch (PngException e) {
throw new IOException(e);
}
};
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
"webp", WEBP,
"apng", APNG
);
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
"image/webp", WEBP,
"image/apng", APNG
);
public static final Set<String> DEFAULT_EXTS = Set.of(
"jpg", "jpeg", "bmp", "gif"
);
public static final Set<String> DEFAULT_CONTENT_TYPES = Set.of(
"image/jpeg", "image/bmp", "image/gif"
);
// ------
public static final int HEADER_BUFFER_SIZE = 1024;
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
private static final byte[] WEBP_HEADER = {'W', 'E', 'B', 'P'};
public static boolean isWebP(byte[] headerBuffer) {
return headerBuffer.length > 12
&& Arrays.equals(headerBuffer, 0, 4, RIFF_HEADER, 0, 4)
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] PNG_HEADER = {
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
};
private static final class PngChunkHeader {
private static final int IDAT_HEADER = 0x49444154;
private static final int acTL_HEADER = 0x6163544c;
private final int length;
private final int chunkType;
private PngChunkHeader(int length, int chunkType) {
this.length = length;
this.chunkType = chunkType;
}
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
if (headerBuffer.remaining() < 8)
return null;
int length = headerBuffer.getInt();
int chunkType = headerBuffer.getInt();
return new PngChunkHeader(length, chunkType);
}
}
public static boolean isApng(byte[] headerBuffer) {
if (headerBuffer.length <= 20)
return false;
if (!Arrays.equals(
headerBuffer, 0, 8,
PNG_HEADER, 0, 8))
return false;
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
PngChunkHeader header;
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
// https://wiki.mozilla.org/APNG_Specification#Structure
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
// The `acTL` structure is described below.
if (header.chunkType == PngChunkHeader.IDAT_HEADER)
break;
if (header.chunkType == PngChunkHeader.acTL_HEADER)
return true;
final int numBytes = header.length + 4;
if (buffer.remaining() > numBytes)
buffer.position(buffer.position() + numBytes);
else
break;
}
return false;
}
public static @Nullable ImageLoader guessLoader(byte[] headerBuffer) {
if (isWebP(headerBuffer))
return WEBP;
if (isApng(headerBuffer))
return APNG;
return null;
}
public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("^\\s(?<type>image/[\\w-])");
// APNG
private static int[] scale(int[] pixels,
int sourceWidth, int sourceHeight,
int targetWidth, int targetHeight) {
assert pixels.length == sourceWidth * sourceHeight;
double xScale = ((double) sourceWidth) / targetWidth;
double yScale = ((double) sourceHeight) / targetHeight;
int[] result = new int[targetWidth * targetHeight];
for (int row = 0; row < targetHeight; row++) {
for (int col = 0; col < targetWidth; col++) {
int sourceX = (int) (col * xScale);
int sourceY = (int) (row * yScale);
int color = pixels[sourceY * sourceWidth + sourceX];
result[row * targetWidth + col] = color;
}
}
return result;
}
private static Image toImage(Argb8888BitmapSequence sequence,
boolean doScale,
int targetWidth, int targetHeight) throws PngException {
final int width = sequence.header.width;
final int height = sequence.header.height;
List<Argb8888BitmapSequence.Frame> frames = sequence.getAnimationFrames();
var framePixels = new int[frames.size()][];
var durations = new int[framePixels.length];
int[] buffer = new int[Math.multiplyExact(width, height)];
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
var frame = frames.get(frameIndex);
PngFrameControl control = frame.control;
if (frameIndex == 0 && (
control.xOffset != 0 || control.yOffset != 0
|| control.width != width || control.height != height)) {
throw new PngIntegrityException("Invalid first frame: " + control);
}
if (control.xOffset < 0 || control.yOffset < 0
|| width < 0 || height < 0
|| control.xOffset + control.width > width
|| control.yOffset + control.height > height
|| control.delayNumerator < 0 || control.delayDenominator < 0
) {
throw new PngIntegrityException("Invalid frame control: " + control);
}
int[] currentFrameBuffer = buffer.clone();
if (control.blendOp == 0) {
for (int row = 0; row < control.height; row++) {
System.arraycopy(frame.bitmap.array,
row * control.width,
currentFrameBuffer,
(control.yOffset + row) * width + control.xOffset,
control.width);
}
} else if (control.blendOp == 1) {
// APNG_BLEND_OP_OVER - Alpha blending
for (int row = 0; row < control.height; row++) {
for (int col = 0; col < control.width; col++) {
int srcIndex = row * control.width + col;
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
int srcPixel = frame.bitmap.array[srcIndex];
int dstPixel = currentFrameBuffer[dstIndex];
int srcAlpha = (srcPixel >>> 24) & 0xFF;
if (srcAlpha == 0) {
continue;
} else if (srcAlpha == 255) {
currentFrameBuffer[dstIndex] = srcPixel;
} else {
int srcR = (srcPixel >>> 16) & 0xFF;
int srcG = (srcPixel >>> 8) & 0xFF;
int srcB = srcPixel & 0xFF;
int dstAlpha = (dstPixel >>> 24) & 0xFF;
int dstR = (dstPixel >>> 16) & 0xFF;
int dstG = (dstPixel >>> 8) & 0xFF;
int dstB = dstPixel & 0xFF;
int invSrcAlpha = 255 - srcAlpha;
int outAlpha = srcAlpha + (dstAlpha * invSrcAlpha + 127) / 255;
int outR, outG, outB;
if (outAlpha == 0) {
outR = outG = outB = 0;
} else {
outR = (srcR * srcAlpha + dstR * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outG = (srcG * srcAlpha + dstG * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outB = (srcB * srcAlpha + dstB * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
}
outAlpha = Math.min(outAlpha, 255);
outR = Math.min(outR, 255);
outG = Math.min(outG, 255);
outB = Math.min(outB, 255);
currentFrameBuffer[dstIndex] = (outAlpha << 24) | (outR << 16) | (outG << 8) | outB;
}
}
}
} else {
throw new PngIntegrityException("Unsupported blendOp " + control.blendOp + " at frame " + frameIndex);
}
if (doScale)
framePixels[frameIndex] = scale(currentFrameBuffer,
width, height,
targetWidth, targetHeight);
else
framePixels[frameIndex] = currentFrameBuffer;
if (control.delayNumerator == 0) {
durations[frameIndex] = 10;
} else {
int durationsMills = 1000 * control.delayNumerator;
if (control.delayDenominator == 0)
durationsMills /= 100;
else
durationsMills /= control.delayDenominator;
durations[frameIndex] = durationsMills;
}
switch (control.disposeOp) {
case 0: // APNG_DISPOST_OP_NONE
System.arraycopy(currentFrameBuffer, 0, buffer, 0, currentFrameBuffer.length);
break;
case 1: // APNG_DISPOSE_OP_BACKGROUND
for (int row = 0; row < control.height; row++) {
int fromIndex = (control.yOffset + row) * width + control.xOffset;
Arrays.fill(buffer, fromIndex, fromIndex + control.width, 0);
}
break;
case 2: // APNG_DISPOSE_OP_PREVIOUS
// Do nothing, keep the previous frame.
break;
default:
throw new PngIntegrityException("Unsupported disposeOp " + control.disposeOp + " at frame " + frameIndex);
}
}
PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays;
if (cycleCount == 0)
cycleCount = Timeline.INDEFINITE;
} else {
cycleCount = Timeline.INDEFINITE;
}
if (doScale)
return new AnimationImageImpl(targetWidth, targetHeight, framePixels, durations, cycleCount);
else
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
}
private ImageUtils() {
}
}
@@ -0,0 +1,57 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.argb8888.*;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngMap;
import org.jackhuang.hmcl.ui.image.apng.map.PngMapReader;
import org.jackhuang.hmcl.ui.image.apng.reader.DefaultPngChunkReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainer;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainerBuilder;
import java.io.InputStream;
/**
* Convenient one liners for loading PNG images.
*/
public class Png {
/**
* Read the provided stream and produce a PngMap of the data.
*
* @param is stream to read from
* @param sourceName optional name, mainly for debugging.
* @return PngMap of the data.
* @throws PngException
*/
public static PngMap readMap(InputStream is, String sourceName) throws PngException {
return PngReadHelper.read(is, new PngMapReader(sourceName));
}
public static PngContainer readContainer(InputStream is) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(new PngContainerBuilder()));
}
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
return PngReadHelper.read(is, reader);
}
public static <ResultT> ResultT read(InputStream is, PngChunkProcessor<ResultT> processor) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException {
Argb8888Processor<Argb8888Bitmap> processor = new Argb8888Processor<>(new DefaultImageArgb8888Director());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException {
Argb8888Processor<Argb8888BitmapSequence> processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
}
@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* A PNG file has an animation type - most commonly, not animated.
* <p>
* This is primarily relevant to tracking whether the default image in a PNG
* is to be
* a) processed as normal (e.g. in a standard or "non-animated" PNG file);
* b) processed and used as the first frame in an animated PNG; or
* c) not processed and discarded (e.g. in an animated PNG which does not use the first frame).
*/
public enum PngAnimationType {
NOT_ANIMATED,
ANIMATED_KEEP_DEFAULT_IMAGE,
ANIMATED_DISCARD_DEFAULT_IMAGE;
}
@@ -0,0 +1,123 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* One four-letter (32-byte) code identifying the type of a PNG chunk.
* <p>
* Common chunk codes are defined as constants that can be used in a switch statement.
* Users can add their own chunk codes separately.
*/
public class PngChunkCode {
public static final PngChunkCode IHDR = new PngChunkCode(PngConstants.IHDR_VALUE, "IHDR");
public static final PngChunkCode PLTE = new PngChunkCode(PngConstants.PLTE_VALUE, "PLTE");
public static final PngChunkCode IDAT = new PngChunkCode(PngConstants.IDAT_VALUE, "IDAT");
public static final PngChunkCode IEND = new PngChunkCode(PngConstants.IEND_VALUE, "IEND");
public static final PngChunkCode gAMA = new PngChunkCode(PngConstants.gAMA_VALUE, "gAMA");
public static final PngChunkCode bKGD = new PngChunkCode(PngConstants.bKGD_VALUE, "bKGD");
public static final PngChunkCode tRNS = new PngChunkCode(PngConstants.tRNS_VALUE, "tRNS");
public static final PngChunkCode acTL = new PngChunkCode(PngConstants.acTL_VALUE, "acTL");
public static final PngChunkCode fcTL = new PngChunkCode(PngConstants.fcTL_VALUE, "fcTL");
public static final PngChunkCode fdAT = new PngChunkCode(PngConstants.fdAT_VALUE, "fdAT");
public final int numeric;
public final String letters;
PngChunkCode(int numeric, String letters) {
this.numeric = numeric;
this.letters = letters;
}
/**
* Find out if the chunk is "critical" as defined by http://www.w3.org/TR/PNG/#5Chunk-naming-conventions
*
* <pre>
* cHNk <-- 32 bit chunk type represented in text form
* ||||
* |||+- Safe-to-copy bit is 1 (lower case letter; bit 5 is 1)
* ||+-- Reserved bit is 0 (upper case letter; bit 5 is 0)
* |+--- Private bit is 0 (upper case letter; bit 5 is 0)
* +---- Ancillary bit is 1 (lower case letter; bit 5 is 1)
* </pre>
*
* @return true if this chunk is considered critical according to the PNG spec.
*/
public boolean isCritical() {
// Ancillary bit: bit 5 o first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) == 0;
}
public boolean isAncillary() {
// Ancillary bit: bit 5 of first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) > 0;
}
public boolean isPrivate() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) > 0;
}
public boolean isPublic() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) == 0;
}
public boolean isReserved() {
return (numeric & PngConstants.BIT_CHUNK_IS_RESERVED) > 0;
}
public boolean isSafeToCopy() {
return (numeric & PngConstants.BIT_CHUNK_IS_SAFE_TO_COPY) > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngChunkCode that = (PngChunkCode) o;
if (numeric != that.numeric) return false;
return !(letters != null ? !letters.equals(that.letters) : that.letters != null);
}
@Override
public int hashCode() {
int result = numeric;
result = 31 * result + (letters != null ? letters.hashCode() : 0);
return result;
}
@Override
public String toString() {
return letters + "(" + numeric + ")";
}
public static PngChunkCode from(int code) {
switch (code) {
case PngConstants.IHDR_VALUE:
return IHDR;
case PngConstants.gAMA_VALUE:
return gAMA;
case PngConstants.bKGD_VALUE:
return bKGD;
case PngConstants.tRNS_VALUE:
return tRNS;
case PngConstants.IDAT_VALUE:
return IDAT;
case PngConstants.IEND_VALUE:
return IEND;
case PngConstants.acTL_VALUE:
return acTL;
case PngConstants.fdAT_VALUE:
return fdAT;
default:
byte[] s = new byte[4];
s[0] = (byte) ((code & 0xff000000) >> 24);
s[1] = (byte) ((code & 0x00ff0000) >> 16);
s[2] = (byte) ((code & 0x0000ff00) >> 8);
s[3] = (byte) ((code & 0x000000ff));
return new PngChunkCode(code, new String(s));
}
}
}
@@ -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));
}
}
}
@@ -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");
}
@@ -0,0 +1,125 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* Each "undo*" function reverses the effect of a filter on a given single scanline.
*/
public class PngFilter {
public static void undoSubFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// int ai = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
//
// final int x = bytes[i];
// final int a = bytes[pixelStart + ai];
int ai = pixelStart;
for (int i = pixelStart + filterUnit; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[ai];
//bytes[rowPosition] = (byte)((bytes[rowPosition] + left) & 0xff); // TODO & 0xff
bytes[i] = (byte) ((x + a) & 0xff);
ai++;
}
}
public static void undoUpFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// for (int i=1; i<bytesPerLine; i++) {
// rowPosition++; // before the first op to skip filter byte
// bytes[rowPosition] = (byte)((bytes[rowPosition] + previousRow[i]) & 0xff);
// }
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte) ((x + b) & 0xff);
bi++;
}
}
public static void undoAverageFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
int ai = pixelStart - filterUnit;
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = (ai < pixelStart) ? 0 : (0xff & bytes[ai]);
final int b = (0xff & previousRow[bi]);
// int ai = pixelStart;
// int bi = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
// final int x = bytes[i];
// final int a = bytes[ai];
// final int b = previousRow[bi];
//bytes[i] = (byte)((x+((a+b)>>1))&0xff);
//int z = x+(a+b)/2;
int z = x + ((a + b) / 2);
bytes[i] = (byte) (0xff & z);
ai++;
bi++;
}
/*
int bi = 0;
for (int i=pixelStart; i<pixelStart+filterUnit; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte)((x + (b >> 1)) & 0xff);
bi++;
}
for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[pixelStart+bi-filterUnit];
final int b = previousRow[bi];
bytes[i] = (byte)((x + ((a+b) >> 1)) & 0xff);
bi++;
}*/
}
/**
* See http://www.libpng.org/pub/png/spec/1.2/PNG-Filters.html
*
*/
public static void undoPaethFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
//int scratch;
//int previousLeft = 0;
//int left = 0;
int ai = pixelStart - filterUnit;
int bi = 0;
int ci = -filterUnit;
//for (int i=0; i<bytesPerLine-1; i++) {
//for (int i=filterUnit; i<bytesPerLine-1; i++) {
for (int i = pixelStart; i < pixelEnd; i++) {
final int a, b, c, x;
x = bytes[i];
if (ai < pixelStart) {
a = c = 0;
} else {
a = 0xff & bytes[ai];
c = 0xff & previousRow[ci];
}
b = 0xff & previousRow[bi];
final int p = a + b - c;
final int pa = p >= a ? p - a : a - p;
final int pb = p >= b ? p - b : b - p;
final int pc = p >= c ? p - c : c - p;
final int predicted = (pa <= pb && pa <= pc) ? a
: (pb <= pc) ? b
: c;
bytes[i] = (byte) ((x + predicted) & 0xff);
ai++;
bi++;
ci++;
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -0,0 +1,72 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import java.util.ArrayList;
import java.util.List;
/**
* An Argb8888BitmapSequence object represents all bitmaps in a single PNG file,
* whether it has one and only one default image, any number of frames in an animation
* or a default image and a separate set of animation frames.
* <p>
* Note that instances of this class will hold an individual bitmap for every frame
* and does <em>not</em> do composition of images in any way. Composition is done in
* the japng_android library using an Android Canvas. This class is not used for that,
* as the intermediate Argb8888Bitmap objects are not required, only one bitmap and
* the output buffer is required during composition.
*/
public final class Argb8888BitmapSequence {
public final PngHeader header;
public final Argb8888Bitmap defaultImage;
private boolean defaultImageIsSet = false;
private PngAnimationControl animationControl;
List<Frame> animationFrames;
public Argb8888BitmapSequence(PngHeader header) {
this.header = header;
this.defaultImage = new Argb8888Bitmap(header.width, header.height);
}
public void receiveAnimationControl(PngAnimationControl animationControl) {
this.animationControl = animationControl;
this.animationFrames = new ArrayList<>(animationControl.numFrames);
}
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
defaultImageIsSet = true;
}
public boolean hasDefaultImage() {
return defaultImageIsSet;
}
public boolean isAnimated() {
return null != animationControl && animationControl.numFrames > 0;
}
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<Frame> getAnimationFrames() {
return animationFrames;
}
public static final class Frame {
public final PngFrameControl control;
public final Argb8888Bitmap bitmap;
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
this.control = control;
this.bitmap = bitmap;
}
}
}
@@ -0,0 +1,82 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888BitmapSequenceDirector instances direct an Argb8888Processor to build all frames
* of an animation into an Argb8888BitmapSequence object.
*/
public class Argb8888BitmapSequenceDirector extends BasicArgb8888Director<Argb8888BitmapSequence> {
Argb8888BitmapSequence bitmapSequence = null;
PngFrameControl currentFrame = null;
private PngHeader header;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
this.header = header;
this.bitmapSequence = new Argb8888BitmapSequence(header);
//this.header = header;
//defaultImage = new Argb8888Bitmap(header.width, header.height);
this.scanlineProcessor = Argb8888Processors.from(header, buffer, this.bitmapSequence.defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return true;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
this.bitmapSequence.receiveDefaultImage(bitmap);
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
this.bitmapSequence.receiveAnimationControl(control);
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
//throw new IllegalStateException("TODO up to here");
//return null;
currentFrame = control;
//System.out.println("Frame: "+control);
return scanlineProcessor.cloneWithNewBitmap(header.adjustFor(control)); // TODO: is this going to be a problem?
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
if (null == currentFrame) {
throw new IllegalStateException("Received a frame image with no frame control in place");
}
if (null == bitmapSequence.animationFrames) {
throw new IllegalStateException("Received a frame image without animation control (or frame list?) in place");
}
bitmapSequence.animationFrames.add(new Argb8888BitmapSequence.Frame(currentFrame, bitmap));
currentFrame = null;
}
@Override
public Argb8888BitmapSequence getResult() {
return bitmapSequence;
}
}
@@ -0,0 +1,47 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888Director implementations "direct" a given Argb8888Processor how to
* control the output. This allows the Argb8888Processor to transform pixels into
* ARGB8888 format while allowing for radically different final output objects,
* e.g. a single bitmap, a sequence of bitamps, an Android View or Drawable, etc.
* <p>
* TODO: not sure if this will stay in this form. Needs refinement.
*/
public interface Argb8888Director<ResultT> {
void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException;
void receivePalette(Argb8888Palette palette);
void processTransparentPalette(byte[] bytes, int position, int length) throws PngException;
void processTransparentGreyscale(byte k1, byte k0) throws PngException;
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException;
boolean wantDefaultImage();
boolean wantAnimationFrames();
Argb8888ScanlineProcessor beforeDefaultImage();
void receiveDefaultImage(Argb8888Bitmap bitmap);
void receiveAnimationControl(PngAnimationControl control);
Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control);
void receiveFrameImage(Argb8888Bitmap bitmap);
ResultT getResult();
}
@@ -0,0 +1,117 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A palette implementation representing colours in ARGB8888 format as 32-bit integers.
* <p>
* Note that the transparency of individual entries in the palette is modified in-place
* when the trNS chunk is processed.
*
* @see BasicArgb8888Director
*/
public class Argb8888Palette {
protected static Argb8888Palette monochromePalette = null;
protected static Argb8888Palette greyPalette2 = null;
protected static Argb8888Palette greyPalette4 = null;
protected static Argb8888Palette greyPalette8 = null;
/**
* The colour array is public and mutable by design.
*
* @see this.get()
*/
public final int[] argbArray;
/**
* Construct a new palette using the specific array passed as the argument as-is (not copied).
*
* @param argbArray array of colours to use as-is (it is not copied).
*/
public Argb8888Palette(int[] argbArray) {
this.argbArray = argbArray;
}
/**
* Retrieve a colour using a method. Users can also just use the argbArray directly.
*
* @param index of colour to retrieve
* @return integer representing the colour at that index.
*/
public int get(int index) {
return this.argbArray[index];
}
/**
* @return the number of colours in this palette, which is the same thing as the size of the array.
*/
public int size() {
return argbArray.length;
}
/**
* Build a new palette by reading the byte array (from the offset position and for length bytes)
* as provided in a PLTE chunk.
*
* @param bytes array to read data from
* @param position offset into bytes array to begin reading from
* @param length number of bytes to read from bytes array
* @return new palette formed by reading the bytes array.
* @throws PngException
*/
public static Argb8888Palette fromPaletteBytes(byte[] bytes, int position, int length) throws PngException {
int numColours = length / 3; // guaranteed to be divisible by 3
int[] argbArray = new int[numColours];
int srcIndex = position;
int alpha = 0xff << 24;
for (int destIndex = 0; destIndex < numColours; destIndex++) {
final int r = bytes[srcIndex++] & 0xff;
final int g = bytes[srcIndex++] & 0xff;
final int b = bytes[srcIndex++] & 0xff;
argbArray[destIndex] = alpha | r << 16 | g << 8 | b;
}
return new Argb8888Palette(argbArray);
}
public static Argb8888Palette forGreyscale(int numEntries, int step) {
int[] array = new int[numEntries];
int alpha = 0xff << 24;
int grey = 0;
for (int i = 0; i < numEntries; i++) {
array[i] = alpha | grey << 16 | grey << 8 | grey;
grey = (grey + step) & 0xff;
}
return new Argb8888Palette(array);
}
public static Argb8888Palette forGreyscale(int bitDepth) throws PngException {
switch (bitDepth) {
case 1:
if (null == monochromePalette) {
monochromePalette = forGreyscale(2, 0xff); // Worth it or not really?
}
return monochromePalette;
case 2:
if (null == greyPalette2) {
greyPalette2 = forGreyscale(4, 0x55);
}
return greyPalette2;
case 4:
if (null == greyPalette4) {
greyPalette4 = forGreyscale(16, 0x11);
}
return greyPalette4;
case 8: // TODO: need??
if (null == greyPalette8) {
greyPalette8 = forGreyscale(256, 0x01);
}
return greyPalette8;
default:
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
}
}
}
@@ -0,0 +1,173 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import java.io.IOException;
import java.io.InputStream;
/**
* Concrete implementation of a chunk processor designed to process pixels into
* 32-bit ARGB8888 format.
*/
public class Argb8888Processor<ResultT> implements PngChunkProcessor<ResultT> { //PngChunkProcessor<Argb8888Bitmap> {
protected PngHeader header = null;
protected PngScanlineBuffer scanlineReader = null;
protected Argb8888Director<ResultT> builder = null;
protected Argb8888ScanlineProcessor scanlineProcessor = null;
public Argb8888Processor(Argb8888Director<ResultT> builder) {
this.builder = builder;
}
@Override
public void processHeader(PngHeader header) throws PngException {
// super.processHeader(header);
//// if (header.bitDepth != 1 || header.colourType != PngColourType.PNG_GREYSCALE) {
//// throw new PngFeatureException("ARGB888 only supports 1-bit greyscale");
//// }
// if (header.isGreyscale()) {
// palette = Argb8888Palette.forGreyscale(header.bitDepth);
// }
// scanlineReader = PngScanlineBuffer.from(header);
this.header = header;
this.scanlineReader = PngScanlineBuffer.from(header);
this.builder.receiveHeader(this.header, this.scanlineReader);
}
@Override
public void processGamma(PngGamma gamma) throws PngException {
// No gamma processing is done at the moment.
}
@Override
public void processPalette(byte[] bytes, int position, int length) throws PngException {
//palette = Argb8888Palette.fromPaletteBytes(bytes, position, length);
//scanlineProcessor = Argb8888Processors.fromPalette(this.header, palette);
builder.receivePalette(Argb8888Palette.fromPaletteBytes(bytes, position, length));
}
@Override
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
switch (header.colourType) {
case PNG_GREYSCALE: // colour type 0
// grey sample value (2 bytes)
if (length != 2) {
throw new PngIntegrityException(String.format("tRNS chunk for greyscale image must be exactly length=2, not %d", length));
}
builder.processTransparentGreyscale(bytes[0], bytes[1]);
break;
case PNG_TRUECOLOUR: // colour type 2
// red, green, blue samples, EACH with two bytes (16-bits)
builder.processTransparentTruecolour(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]);
break;
case PNG_INDEXED_COLOUR: // colour type 3
// This is a sequence of one-byte alpha values to apply to each palette entry starting at zero.
// The number of entries may be less than the size of the palette, but not more.
builder.processTransparentPalette(bytes, position, length);
break;
case PNG_GREYSCALE_WITH_ALPHA:
case PNG_TRUECOLOUR_WITH_ALPHA:
default:
throw new PngIntegrityException("Illegal to have tRNS chunk with image type " + header.colourType.name);
}
}
/**
* The only supported animation type is not "NOT_ANIMATED".
*/
// @Override
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
// scanlineProcessor = Argb8888ScanlineProcessor.from(header, scanlineReader, currentFrame);
// return PngAnimationType.NOT_ANIMATED;
// }
@Override
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (!builder.wantDefaultImage()) {
inputStream.skip(length); // important!
return;
}
if (null == scanlineProcessor) { // TODO: is that a good enough metric? Or could be numIdat==0?
scanlineProcessor = builder.beforeDefaultImage();
if (null == scanlineProcessor) throw new IllegalStateException("Builder must create scanline processor");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveDefaultImage(scanlineProcessor.getBitmap());
scanlineProcessor = null;
scanlineReader.reset();
}
}
@Override
public void processAnimationControl(PngAnimationControl animationControl) throws PngException {
if (builder.wantAnimationFrames()) {
builder.receiveAnimationControl(animationControl);
}
}
@Override
public void processFrameControl(PngFrameControl frameControl) throws PngException {
if (!builder.wantAnimationFrames()) {
return;
}
if (null == scanlineProcessor) {
// If here, the data in this frame image has not started
scanlineProcessor = builder.receiveFrameControl(frameControl);
if (null == scanlineProcessor)
throw new IllegalStateException("Builder must create scanline processor for frame");
} else {
throw new IllegalStateException("received animation frame control but image data was in progress");
}
}
@Override
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
//throw new PngFeatureException("PngArgb8888Processor does not support animation frames");
if (!builder.wantAnimationFrames()) {
inputStream.skip(length);
return;
}
if (null == scanlineProcessor) {
throw new IllegalStateException("received animation frame image data before frame control or without processor in place");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveFrameImage(scanlineProcessor.getBitmap());
scanlineReader.reset();
scanlineProcessor = null;
}
}
@Override
public void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException {
// NOP
}
@Override
public ResultT getResult() {
//return scanlineProcessor.getBitmap();
return builder.getResult();
}
}
@@ -0,0 +1,462 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A series of scanline processor implementations for different input pixel formats,
* each able to transform the input into ARGB8888 output pixels.
*/
public class Argb8888Processors {
/**
* Determine from the header the concrete Argb8888Processor implementation the
* caller needs to transform the incoming pixels into the ARGB8888 pixel format.
*
* @param header of the image being loaded
* @param scanlineReader the scanline buffer being used
* @param bitmap the destination bitmap
* @return a concrete Argb8888Processor to transform source pixels into the specific bitmap.
* @throws PngException if there is a feature not supported or some specification break with the file.
*/
public static Argb8888ScanlineProcessor from(PngHeader header, PngScanlineBuffer scanlineReader, Argb8888Bitmap bitmap) throws PngException {
int bytesPerScanline = header.bytesPerRow;
switch (header.colourType) {
case PNG_GREYSCALE:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1, Argb8888Palette.forGreyscale(1));
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2, Argb8888Palette.forGreyscale(2));
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4, Argb8888Palette.forGreyscale(4));
case 8:
return new Greyscale8(bytesPerScanline, bitmap);
case 16:
throw new PngFeatureException("Greyscale supports 1, 2, 4, 8 but not 16.");
default:
throw new PngIntegrityException(String.format("Invalid greyscale bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_GREYSCALE_WITH_ALPHA:
switch (header.bitDepth) {
case 4:
return new Greyscale4Alpha(bytesPerScanline, bitmap);
case 8:
return new Greyscale8Alpha(bytesPerScanline, bitmap);
case 16:
return new Greyscale16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid greyscale-with-alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_INDEXED_COLOUR:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1);
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2);
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4);
case 8:
return new IndexedColour8(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid indexed colour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR:
switch (header.bitDepth) {
case 8:
return new Truecolour8(bytesPerScanline, bitmap);
case 16:
return new Truecolour16(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR_WITH_ALPHA:
switch (header.bitDepth) {
case 8:
return new Truecolour8Alpha(bytesPerScanline, bitmap);
case 16:
return new Truecolour16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour with alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
default:
throw new PngFeatureException("ARGB8888 doesn't support PNG mode " + header.colourType.name());
}
}
/**
* Transforms 1-, 2-, 4-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColourBits extends Argb8888ScanlineProcessor {
private int highBit;
private int mask;
private byte[] shifts;
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts) {
this(bytesPerScanline, bitmap, highBit, mask, shifts, null);
}
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts, Argb8888Palette palette) {
super(bytesPerScanline, bitmap);
this.highBit = highBit;
this.mask = mask;
this.shifts = shifts;
this.palette = palette;
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
int lastWritePosition = writePosition + width;
int bit = highBit;
while (writePosition < lastWritePosition) {
//final int index=(srcBytes[srcPosition+x] & masks[bit]) >> shifts[bit];
//final int index = (srcBytes[srcPosition])
//final int v = srcBytes[srcPosition];
final int index = mask & (srcBytes[srcPosition] >> shifts[bit]);
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
if (bit == 0) {
srcPosition++;
bit = highBit;
} else {
bit--;
}
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColourBits(bytesPerRow, bitmap, highBit, mask, shifts, palette);
}
}
/**
* Special case implementation to transform 8-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColour8 extends Argb8888ScanlineProcessor {
public IndexedColour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int index = 0xff & srcBytes[srcPosition++]; // TODO: need to use transparency and background chunks
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColour8(bytesPerRow, bitmap);
}
}
/**
* Transforms 4-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale4Alpha extends Argb8888ScanlineProcessor {
public Greyscale4Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int v = srcBytes[srcPosition++];
final int k = PngConstants.GREY_PALETTE_16[0x0f & (v >> 4)];
final int a = PngConstants.GREY_PALETTE_16[0x0f & v];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale4Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale source pixels to ARGB8888 pixels.
*/
public static class Greyscale8 extends Argb8888ScanlineProcessor {
boolean haveTransparent = false;
int transparentSample = 0;
public Greyscale8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int sample = 0xff & srcBytes[srcPosition++];
final int k = (haveTransparent && sample == transparentSample) ? transparentSample : sample;
final int c = alpha | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) {
// According to below, when image is less than 16-bits per pixel, use least significant byte
// http://www.w3.org/TR/PNG/#11transinfo
haveTransparent = true;
transparentSample = 0xff & k0; // NOT k1 according to http://www.w3.org/TR/PNG/#11transinfo
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale8Alpha extends Argb8888ScanlineProcessor {
public Greyscale8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 16-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale16Alpha extends Argb8888ScanlineProcessor {
public Greyscale16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the grey
final int a = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the alpha
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale16Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8 extends Argb8888ScanlineProcessor {
public Truecolour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8Alpha extends Argb8888ScanlineProcessor {
public Truecolour8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 16-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour16 extends Argb8888ScanlineProcessor {
public Truecolour16(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 16-bit source pixels to ARGB8888 pixels.
* <p>
* Note that the simpler method of resampling the colour is done, namely discard the LSB.
*/
public static class Truecolour16Alpha extends Argb8888ScanlineProcessor {
public Truecolour16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
//final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int alpha = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int c = alpha << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16Alpha(bytesPerRow, bitmap);
}
}
private Argb8888Processors() {
}
}
@@ -0,0 +1,84 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.BasicScanlineProcessor;
/**
* Base implementation that transforms scanlines of source pixels into scanlines of
* ARGB8888 (32-bit integer) pixels in a destination Argb8888Bitmap object.
* <p>
* Note: I wonder if a better name is ScanlineConverter or PixelTransformer.
*
* @see Argb8888Processors
*/
public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
protected Argb8888Bitmap bitmap;
protected Argb8888Palette palette;
protected int y = 0;
// public Argb8888ScanlineProcessor(PngHeader header) {
// this(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
// }
public Argb8888ScanlineProcessor(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline);
this.bitmap = bitmap;
}
// public Argb8888ScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
// super(header, scanlineReader, currentFrame);
// bitmap = new Argb8888Bitmap(this.header.width, this.header.height);
// }
public Argb8888Bitmap getBitmap() {
return bitmap;
}
public Argb8888Palette getPalette() {
return palette;
}
public void setPalette(Argb8888Palette palette) {
this.palette = palette;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to a new bitmap.
*/
public Argb8888ScanlineProcessor cloneWithNewBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to the same bitmap as the current processor.
*/
public Argb8888ScanlineProcessor cloneWithSharedBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, bitmap.makeView(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
// public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
// throw new IllegalStateException("TODO: override");
// }
abstract Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap);
}
@@ -0,0 +1,45 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* Common functionality for Argb8888Director implementations.
*/
public abstract class BasicArgb8888Director<ResultT> implements Argb8888Director<ResultT> {
protected Argb8888ScanlineProcessor scanlineProcessor;
@Override
public void receivePalette(Argb8888Palette palette) {
scanlineProcessor.setPalette(palette);
}
@Override
public void processTransparentPalette(byte[] bytes, int position, int length) throws PngException {
Argb8888Palette palette = scanlineProcessor.getPalette();
if (null == palette) {
throw new PngIntegrityException("Received tRNS data but no palette is in place");
}
if (length <= 0 || length > palette.size()) {
throw new PngIntegrityException(String.format("Received tRNS data length is invalid. Should be >1 && < %d but is %d", palette.size(), length));
}
for (int i = 0; i < length; i++) {
final int alpha = 0xff & bytes[position + i];
palette.argbArray[i] = alpha << 24 | palette.argbArray[i] & 0x00FFFFFF;
}
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) throws PngException {
scanlineProcessor.processTransparentGreyscale(k1, k0);
}
@Override
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException {
scanlineProcessor.processTransparentTruecolour(r1, r0, g1, g0, b1, b0);
}
}
@@ -0,0 +1,65 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* This will build a single bitmap: the default image within the PNG file.
* Any animation data (or any other data) will not be processed.
*/
public class DefaultImageArgb8888Director extends BasicArgb8888Director<Argb8888Bitmap> {
protected Argb8888Bitmap defaultImage;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
defaultImage = new Argb8888Bitmap(header.width, header.height);
scanlineProcessor = Argb8888Processors.from(header, buffer, defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return false;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
return null;
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
}
@Override
public Argb8888Bitmap getResult() {
return defaultImage;
}
}
@@ -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 +
'}';
}
}
@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.util.ArrayList;
import java.util.List;
/**
* A PngFrameControl object contains data parsed from the ``fcTL`` chunk data
* in an animated PNG File.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
* <pre>
* 0 sequence_number (unsigned int) Sequence number of the animation chunk, starting from 0
* 4 width (unsigned int) Width of the following frame
* 8 height (unsigned int) Height of the following frame
* 12 x_offset (unsigned int) X position at which to render the following frame
* 16 y_offset (unsigned int) Y position at which to render the following frame
* 20 delay_num (unsigned short) Frame delay fraction numerator
* 22 delay_den (unsigned short) Frame delay fraction denominator
* 24 dispose_op (byte) Type of frame area disposal to be done after rendering this frame
* 25 blend_op (byte) Type of frame area rendering for this frame
* </pre>
* <p>
* Delay denominator: from spec, "if denominator is zero it should be treated as 100ths of second".
* <pre>
* dispose op:
* value
* 0 APNG_DISPOSE_OP_NONE
* 1 APNG_DISPOSE_OP_BACKGROUND
* 2 APNG_DISPOSE_OP_PREVIOUS
*
* blend op:
* value
* 0 APNG_BLEND_OP_SOURCE
* 1 APNG_BLEND_OP_OVER
* </pre>
*/
public class PngFrameControl {
public final int sequenceNumber;
public final int width;
public final int height;
public final int xOffset;
public final int yOffset;
public final short delayNumerator;
public final short delayDenominator;
public final byte disposeOp;
public final byte blendOp;
List<PngChunkMap> imageChunks = new ArrayList<>(1); // TODO: this may be removed
public PngFrameControl(int sequenceNumber, int width, int height, int xOffset, int yOffset, short delayNumerator, short delayDenominator, byte disposeOp, byte blendOp) {
this.sequenceNumber = sequenceNumber;
this.width = width;
this.height = height;
this.xOffset = xOffset;
this.yOffset = yOffset;
this.delayNumerator = delayNumerator;
this.delayDenominator = delayDenominator == 0 ? 100 : delayDenominator; // APNG spec says zero === 100.
this.disposeOp = disposeOp;
this.blendOp = blendOp;
}
/**
* @return number of milliseconds to show this frame for
*/
public int getDelayMilliseconds() {
if (delayDenominator == 1000) {
return delayNumerator;
} else {
// if denom is 100 then need to multiple by 10
float f = 1000 / delayDenominator; // 1000/100 -> 10
return (int) (delayNumerator * f);
}
}
// TODO: can this be removed?
public void appendImageData(PngChunkMap chunkMap) {
imageChunks.add(chunkMap);
}
// TODO: this may be removed
public List<PngChunkMap> getImageChunks() {
return imageChunks;
}
@Override
public String toString() {
return "PngFrameControl{" +
"sequenceNumber=" + sequenceNumber +
", width=" + width +
", height=" + height +
", xOffset=" + xOffset +
", yOffset=" + yOffset +
", delayNumerator=" + delayNumerator +
", delayDenominator=" + delayDenominator +
", disposeOp=" + disposeOp +
", blendOp=" + blendOp +
'}';
}
// mainly for ease in unit testing
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngFrameControl that = (PngFrameControl) o;
if (sequenceNumber != that.sequenceNumber) return false;
if (width != that.width) return false;
if (height != that.height) return false;
if (xOffset != that.xOffset) return false;
if (yOffset != that.yOffset) return false;
if (delayNumerator != that.delayNumerator) return false;
if (delayDenominator != that.delayDenominator) return false;
if (disposeOp != that.disposeOp) return false;
return blendOp == that.blendOp;
}
@Override
public int hashCode() {
int result = sequenceNumber;
result = 31 * result + width;
result = 31 * result + height;
result = 31 * result + xOffset;
result = 31 * result + yOffset;
result = 31 * result + (int) delayNumerator;
result = 31 * result + (int) delayDenominator;
result = 31 * result + (int) disposeOp;
result = 31 * result + (int) blendOp;
return result;
}
}
@@ -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());
}
}
@@ -0,0 +1,224 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import java.io.DataInput;
import java.io.IOException;
/**
* Created by aellerton on 10/05/2015.
*/
public class PngHeader {
/**
* Number of pixels (columns) wide.
*/
public final int width;
/**
* Number of pixels (rows) high.
*/
public final int height;
/**
* The bitDepth is the number of bits for each <em>channel</em> of a given pixel.
* A better name might be "bitsPerPixelChannel" but the name "bitDepth" is used
* throughout the PNG specification.
* <p>
* A truecolour image with a bitDepth of 8 means that the red channel of a pixel
* has 8 bits (so 256 levels of red), green has 8 bits (256 levels of green), and
* blue has 8 bits (so 256 levels of green). That means the total bitsPerPixel
* for that bitmap will be 8+8+8 = 24.
* <p>
* A truecolour <em>with alpha</em> image with bitDepth of 8 will be the same
* except every alpha element of every pixel will have 8 bits (so 256 levels of
* alpha transparency), meaning that the total bitsPerPixel for that bitmap will
* be 8+8+8+8=32.
* <p>
* A truecolour with alpha image with <em>bitDepth of 16</em> means that each of
* red, green blue and alpha have 16-bits respectively, meaning that the total
* bitsPerPixel will be 16+16+16+16 = 64.
* <p>
* A greyscale image (no alpha) with bitDepth of 16 has only a grey channel for
* each pixel, so the bitsPerPixel will also be 16.
* <p>
* But a greyscale image <em>with alpha</em> with a bitDepth of 16 has a grey
* channel and an alpha channel, each with 16 bits so the bitsPerPixel will be
* 16+16=32.
* <p>
* As for palette-based images...
* <ul>
* <li>A monochrome image or image with 2 colour palette has bitDepth=1.</li>
* <li>An image with 4 colour palette has bitDepth=2.</li>
* <li>An image with 8 colour palette has bitDepth=3.</li>
* <li>An image with 16 colour palette has bitDepth=4.</li>
* <li>A greyscale image with 16 levels of gray <em>and an alpha channel</em>
* has bitDepth=4 and bitsPerPixel=8 because the gray and the alpha channel
* each have 4 bits.</li>
* </ul>
*
* @see #bitsPerPixel
*/
public final byte bitDepth;
/**
* Every PNG image must be exactly one of the standard types as defined by the
* PNG specification. Better names might have been "imageType" or "imageFormat"
* but the name "colourType" is used throughout the PNG spec.
*/
public final PngColourType colourType;
/**
* Compression type of the file.
* In practice this is redundant: it may be zip and nothing else.
*/
public final byte compressionMethod;
/**
* Filter method used by the file.
* In practice this is redundant because the filter types are set in the
* specification and have never been (and never will be) extended.
*/
public final byte filterMethod;
/**
* An image is either interlaced or not interlaced.
* At the time of writing only non-interlaced is supported by this library.
*/
public final byte interlaceMethod;
/**
* The number of bits that comprise a single pixel in this bitmap (or every
* frame if animated). This is distinct from bitDepth.
*
* @see #bitDepth
*/
public final int bitsPerPixel;
public final int bytesPerRow;
public final int filterOffset;
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType) {
this(width, height, bitDepth, colourType, (byte) 0, (byte) 0, (byte) 0);
}
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) {
this.width = width;
this.height = height;
this.bitDepth = bitDepth;
this.colourType = colourType;
this.compressionMethod = compressionMethod;
this.filterMethod = filterMethod;
this.interlaceMethod = interlaceMethod;
this.bitsPerPixel = bitDepth * colourType.componentsPerPixel;
this.bytesPerRow = PngReadHelper.calculateBytesPerRow(width, bitDepth, colourType, interlaceMethod);
//this.filterOffset = this.bitsPerPixel < 8 ? 1 : this.bitsPerPixel>>3; // minimum of 1 byte. RGB888 will be 3 bytes. RGBAFFFF is 8 bytes.
this.filterOffset = (this.bitsPerPixel + 7) >> 3; // libpng
// from pypng
// # Derived values
// # http://www.w3.org/TR/PNG/#6Colour-values
// colormap = bool(self.color_type & 1)
// greyscale = not (self.color_type & 2)
// alpha = bool(self.color_type & 4)
// color_planes = (3,1)[greyscale or colormap]
// planes = color_planes + alpha
}
@Override
public String toString() {
return "PngHeader{" +
"width=" + width +
", height=" + height +
", bitDepth=" + bitDepth +
", colourType=" + colourType +
", compressionMethod=" + compressionMethod +
", filterMethod=" + filterMethod +
", interlaceMethod=" + interlaceMethod +
'}';
}
public boolean isInterlaced() {
return interlaceMethod == 1;
}
public boolean isZipCompression() {
return compressionMethod == 0;
}
public boolean isGreyscale() {
return colourType == PngColourType.PNG_GREYSCALE | colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA;
}
/**
* @return true if the image type indicates there is an alpha value for every pixel.
* Note that this take into account any transparency or background chunk.
*/
public boolean hasAlphaChannel() {
return colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA | colourType == PngColourType.PNG_TRUECOLOUR_WITH_ALPHA;
}
public static PngHeader makeTruecolour(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR);
}
public static PngHeader makeTruecolourAlpha(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR_WITH_ALPHA);
}
public PngHeader adjustFor(PngFrameControl frame) {
if (frame == null) {
return this;
} else {
return new PngHeader(frame.width, frame.height, this.bitDepth, this.colourType, this.compressionMethod, this.filterMethod, this.interlaceMethod);
}
}
public static void checkHeaderParameters(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) throws PngException {
switch (bitDepth) {
case 1:
case 2:
case 4:
case 8:
case 16:
break; // all fine
default:
throw new PngIntegrityException("Invalid bit depth " + bitDepth);
}
// thanks to pypng
if (colourType.isIndexed() && bitDepth > 8) {
throw new PngIntegrityException(String.format(
"Indexed images (colour type %d) cannot have bitdepth > 8 (bit depth %d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (bitDepth < 8 && !colourType.supportsSubByteDepth()) {
throw new PngIntegrityException(String.format(
"Illegal combination of bit depth (%d) and colour type (%d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (interlaceMethod != 0) {
throw new PngFeatureException("Interlaced images are not yet supported");
}
}
public static PngHeader from(DataInput dis) throws IOException, PngException {
int width = dis.readInt();
int height = dis.readInt();
byte bitDepth = dis.readByte();
PngColourType colourType = PngColourType.fromByte(dis.readByte());
byte compressionMethod = dis.readByte();
byte filterMethod = dis.readByte();
byte interlaceMethod = dis.readByte();
checkHeaderParameters(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
return new PngHeader(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
}
}
@@ -0,0 +1,61 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Palette;
import java.util.Arrays;
/**
* A PngPalette object represents an ordered array of RGB (888) colour tuples
* derived from a PLTE chunk.
* <p>
* WARNING: this class may not remain in the API.
* When implementing the Argb8888 decoders it seems clear that every output
* format will benefit from a specific palette implementation, so this attempt
* at a generic palette may be removed.
*
* @see Argb8888Palette
*/
public class PngPalette {
// TODO: should include alpha here? Can then store as int32s?
public final byte[] rgb888;
public final int[] rgba8888; // Including this duplicate for now. Not sure if will keep it.
public final int numColours;
public static final int LENGTH_RGB_BYTES = 3;
public static final int BYTE_INITIAL_ALPHA = 0xff;
public PngPalette(byte[] rgb888, int[] rgba8888) {
this.rgb888 = rgb888;
this.rgba8888 = rgba8888;
this.numColours = rgb888.length / 3;
}
public static PngPalette from(byte[] source, int first, int length) throws PngException {
if (length % LENGTH_RGB_BYTES != 0) {
throw new PngIntegrityException(String.format("Invalid palette data length: %d (not a multiple of 3)", length));
}
return new PngPalette(
Arrays.copyOfRange(source, first, first + length),
rgba8888From(source, first, length)
);
}
private static int[] rgba8888From(byte[] source, int first, int length) {
int last = first + length;
int numColours = length / 3;
int[] rgba8888 = new int[numColours];
int j = 0;
for (int i = first; i < last; i += LENGTH_RGB_BYTES) {
rgba8888[j] = source[i] << 24 | source[i + 1] << 16 | source[i + 2] << 8 | BYTE_INITIAL_ALPHA;
j++;
}
return rgba8888;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
/**
* A PngIntegrityException is thrown when some aspect of the PNG file being
* loaded is invalid or unacceptable according to the PNG Specification.
* <p>
* For example, requesting a 16-bit palette image is invalid, or a colour type
* outside of the allowed set.
*/
public class PngIntegrityException extends PngException {
public PngIntegrityException(String message) {
super(PngConstants.ERROR_INTEGRITY, message);
}
}
@@ -0,0 +1,52 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
/**
* A single chunk from a PNG file is represented by a PngChunkMap.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngChunkMap {
/**
* The code like IHDR, IPAL, IDAT. If the chunk code is non-standard,
* the UNKNOWN code will be used and the codeString will be set.
*/
public PngChunkCode code;
/**
* Number of bytes containing the data portion of the chunk. Note that this excludes
* the last 4 bytes that are the CRC checksom.
*/
public int dataLength;
/**
* Integer offset of the first byte of data in this chunk (the byte immediately
* following the chunk length 32-bits and the chunk type 32-bits) from byte zero
* of the source.
*/
public int dataPosition;
public int checksum;
public PngChunkMap(PngChunkCode code, int dataPosition, int dataLength, int checksum) {
this.code = code;
this.dataLength = dataLength;
this.dataPosition = dataPosition;
this.checksum = checksum;
}
@Override
public String toString() {
return "PngChunkMap{" +
"letters=" + code +
", dataLength=" + dataLength +
", dataPosition=" + dataPosition +
", checksum=" + checksum +
'}';
}
}
@@ -0,0 +1,17 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import java.util.List;
/**
* A PngMap represents a map over an entire single PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMap {
public String source;
public List<PngChunkMap> chunks;
}
@@ -0,0 +1,50 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngSource;
import java.io.IOException;
import java.util.ArrayList;
/**
* Simple processor that skips all chunk content and ignores checksums, with
* sole objective of building a map of the contents of a PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMapReader implements PngReader<PngMap> {
PngMap map;
public PngMapReader(String sourceName) {
map = new PngMap();
map.source = sourceName;
map.chunks = new ArrayList<>(4);
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell();
source.skip(dataLength);
int chunkChecksum = source.readInt();
map.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
return code == PngConstants.IEND_VALUE;
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
// NOP
}
@Override
public PngMap getResult() {
return map;
}
}
@@ -0,0 +1,4 @@
/**
* @see <a href="https://github.com/aellerton/japng">japng</a>
*/
package org.jackhuang.hmcl.ui.image.apng;
@@ -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
}
}
@@ -0,0 +1,303 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngAnimationType;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
/**
* The DefaultPngChunkReader is the default PNG chunk-reading workhorse.
* <p>
* Note that any chunk types not recognised can be processed in the readOtherChunk()
* method.
*/
public class DefaultPngChunkReader<ResultT> implements PngChunkReader<ResultT> {
protected PngChunkProcessor<ResultT> processor;
protected boolean seenHeader = false;
protected int idatCount = 0;
protected int apngSequenceExpect = 0;
protected PngAnimationType animationType = PngAnimationType.NOT_ANIMATED;
//private PngMainImageOp mainImageOp = PngMainImageOp.MAIN_IMAGE_KEEP;
public DefaultPngChunkReader(PngChunkProcessor<ResultT> processor) {
this.processor = processor;
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell(); // note the start position before any further reads are done.
if (dataLength < 0) {
throw new PngIntegrityException(String.format("Corrupted read (Data length %d)", dataLength));
}
switch (code) {
case PngConstants.IHDR_VALUE:
readHeaderChunk(source, dataLength);
break;
case PngConstants.IEND_VALUE:
// NOP
break;
case PngConstants.gAMA_VALUE:
readGammaChunk(source, dataLength);
break;
case PngConstants.bKGD_VALUE:
readBackgroundChunk(source, dataLength);
break;
case PngConstants.tRNS_VALUE:
readTransparencyChunk(source, dataLength);
break;
case PngConstants.PLTE_VALUE:
readPaletteChunk(source, dataLength);
break;
case PngConstants.IDAT_VALUE:
readImageDataChunk(source, dataLength);
break;
case PngConstants.acTL_VALUE:
readAnimationControlChunk(source, dataLength);
break;
case PngConstants.fcTL_VALUE:
readFrameControlChunk(source, dataLength);
break;
case PngConstants.fdAT_VALUE:
readFrameImageDataChunk(source, dataLength);
break;
default:
readOtherChunk(code, source, dataPosition, dataLength);
break;
}
int chunkChecksum = source.readInt();
processChunkEnd(code, dataPosition, dataLength, chunkChecksum);
return code == PngConstants.IEND_VALUE;
}
@Override
public void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException {
processor.processChunkMapItem(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
//container.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataLength, dataPosition, chunkChecksum));
}
@Override
public void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException {
PngHeader header = PngHeader.from(source.getDis());
seenHeader = true;
processor.processHeader(header); // Hmm. Prefer header = PngHeader.from(source) ?
}
@Override
public void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException {
processor.processGamma(PngGamma.from(source.getDis()));
}
@Override
public void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException {
// TODO
//processor.processTransparency(PngTransparency.from(source, dataLength));
//throw new PngFeatureException("TODO UP TO HERE");
//source.skip(dataLength);
// if (dataLength % 3 != 0) {
// throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
// }
if (source.supportsByteAccess()) {
processor.processTransparency(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processTransparency(paletteBytes, 0, dataLength);
}
}
@Override
public void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException {
if (!seenHeader) {
throw new PngIntegrityException("bKGD chunk received before IHDR chunk");
}
// TODO
//processor.processBackground(PngBackground.from(source, dataLength);
source.skip(dataLength);
}
@Override
public void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength % 3 != 0) {
throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
}
// TODO: can check if colour type matches palette type, or if any palette received before (overkill?)
if (source.supportsByteAccess()) {
processor.processPalette(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processPalette(paletteBytes, 0, dataLength);
}
}
@Override
public void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException {
if (idatCount == 0 && apngSequenceExpect == 0) {
// Processing a plain PNG (non animated) IDAT chunk
animationType = PngAnimationType.NOT_ANIMATED; // processor.chooseApngImageType(PngAnimationType.NOT_ANIMATED, null);
}
idatCount++;
switch (animationType) {
case ANIMATED_DISCARD_DEFAULT_IMAGE:
// do nothing
source.skip(dataLength);
break;
case ANIMATED_KEEP_DEFAULT_IMAGE:
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
case NOT_ANIMATED:
default:
processor.processDefaultImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
}
// source.skip(dataLength);
}
@Override
public void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_acTL_CHUNK) {
throw new PngIntegrityException(String.format("acTL chunk length must be %d, not %d", PngConstants.LENGTH_acTL_CHUNK, dataLength));
}
processor.processAnimationControl(new PngAnimationControl(source.readInt(), source.readInt()));
}
@Override
public void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_fcTL_CHUNK) {
throw new PngIntegrityException(String.format("fcTL chunk length must be %d, not %d", PngConstants.LENGTH_fcTL_CHUNK, dataLength));
}
int sequence = source.readInt(); // TODO: check sequence # is correct or PngIntegrityException
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fctl chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // ready for next time
PngFrameControl frame = new PngFrameControl(
sequence,
source.readInt(), // width
source.readInt(), // height
source.readInt(), // x offset
source.readInt(), // y offset
source.readUnsignedShort(), // delay numerator
source.readUnsignedShort(), // delay denominator
source.readByte(), // dispose op
source.readByte() // blend op
);
if (sequence == 0) { // We're at the first frame...
if (idatCount == 0) { // Not seen any IDAT chunks yet
// APNG Spec says that when the first fcTL chunk is received *before* the first IDAT chunk
// the main image of the PNG becomes the first frame in the animation.
animationType = PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE; //processor.chooseApngImageType(PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE, frame);
//mainImageOp = PngMainImageOp.MAIN_IMAGE_STARTS_ANIMATION;
} else {
//mainImageOp = PngMainImageOp.MAIN_IMAGE_DISCARD;
animationType = PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE; // processor.chooseApngImageType(PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE, frame);
}
} else {
// fall through
}
processor.processFrameControl(frame);
}
//public abstract void setMainImageOp(PngMainImageOp op);
@Override
public void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException {
// Note that once the sequence number is confirmed as being correct that there
// is no need to retain it in subsequent data.
int position = source.tell();
int sequence = source.readInt();
dataLength -= 4; // After reading the sequence number the data is just like IDAT.
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fdAT chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // for next time
//processFrameData(sequence, source, dataLength);
//processFrameImageData(source, dataLength);
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.fdAT, source.tell(), dataLength);
// PngFrameControl current = container.getCurrentAnimationFrame();
//
// //imageDecoder
// // TODO: send image bytes to digester
// current.appendImageData(new PngChunkMap(PngChunkCode.fdAT, dataLength, position, 0));
//
// // TODO: skip everything except the frame sequence number
// source.skip(dataLength);
}
@Override
public void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException {
// If we're not processing it, got to skip it.
source.skip(dataLength);
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
}
@Override
public ResultT getResult() {
return processor.getResult();
}
public boolean isSeenHeader() {
return seenHeader;
}
public int getIdatCount() {
return idatCount;
}
public int getApngSequenceExpect() {
return apngSequenceExpect;
}
public PngAnimationType getAnimationType() {
return animationType;
}
}
@@ -0,0 +1,109 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* A simple way to process a PNG file is to read the entire thing into memory.
* <p>
* Very bad for very large images (500Mb?)? Yes.
* But simple for smaller (say <10Mb?) images? Also yes.
* I'd certainly like to make a lean alternative that processes bytes without reading
* the whole thing into memory, but it can come later. Patches welcome.
* <p>
* WARNING: I'm not sure I'll keep this. I may remove it and do everything with
* a BufferedInputStream + DataInputStream.
* </p>
*/
public class PngAtOnceSource implements PngSource {
final byte[] bytes;
//private String sourceDescription;
private final ByteArrayInputStream bis;
private final DataInputStream dis;
public PngAtOnceSource(byte[] bytes) { //}, String sourceDescription) {
this.bytes = bytes;
//this.sourceDescription = sourceDescription;
this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
this.dis = new DataInputStream(this.bis); // never closed because underlying stream doesn't need to be closed.
}
@Override
public int getLength() {
return bytes == null ? 0 : bytes.length;
}
@Override
public boolean supportsByteAccess() {
return true;
}
@Override
public byte[] getBytes() throws IOException {
return bytes;
}
@Override
public byte readByte() throws IOException {
return dis.readByte();
}
@Override
public short readUnsignedShort() throws IOException {
return (short) dis.readUnsignedShort();
}
@Override
public int readInt() throws IOException {
return dis.readInt();
}
@Override
public long skip(int chunkLength) throws IOException {
return dis.skip(chunkLength);
}
@Override
public int tell() {
return bytes.length - bis.available();
}
@Override
public int available() {
return bis.available();
}
// @Override
// public String getSourceDescription() {
// return sourceDescription;
// }
// @Override
// public ByteArrayInputStream getBis() {
// return bis;
// }
@Override
public DataInputStream getDis() {
return dis;
}
@Override
public InputStream slice(int dataLength) throws IOException {
// The below would be fine but in this case we have the full byte stream anyway...
//return ByteStreams.limit(bis, dataLength);
InputStream slice = new ByteArrayInputStream(bytes, tell(), dataLength);
this.skip(dataLength);
return slice;
}
public static PngAtOnceSource from(InputStream is) throws IOException {
return new PngAtOnceSource(is.readAllBytes());
}
}
@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
import java.io.InputStream;
/**
* While a PngChunkReader does the low-level reading of a PNG file, it delegates <em>processing</em> of
* the data to a PngChunkProcessor instance. The class is generic about a specific result type because
* the intention of a chunk processor is to yield some result, which might be a bitmap, a sequence of
* bitmaps, a map of chunks, etc.
*/
public interface PngChunkProcessor<ResultT> {
/**
* Subclasses can further process the header immediately after PngHeader has been read.
* A subclass might elect to bail out of subsequent processing, e.g. if the image is too big
* or has an invalid format.
*
* @param header of image
*/
void processHeader(PngHeader header) throws PngException;
/**
* Process the PngGamma object parsed from a ``gaMA`` chunk.
*
* @param gamma parsed gamma data.
* @throws PngException
*/
void processGamma(PngGamma gamma) throws PngException;
/**
* Process the raw trNS data from the PNG file.
* <p>
* Note that this data is passed raw (but in a complete byte array).
* It is not parsed into an object because it allows concrete implementations to define
* their own way to handle the data. And it is not provided as in InputStream because
* the length of the data is well defined based on the PNG colour type.
*
* @param bytes array to read data from.
* @param position offset into the array to begin reading data from.
* @param length number of bytes to read from the array.
* @throws PngException
*/
void processTransparency(byte[] bytes, int position, int length) throws PngException;
/**
* A PLTE chunk has been read and needs to be processed.
* <p>
* A byte array is provided to process. A pre-defined palette class is not loaded because
* different rendering targets may elect to load the palette in different ways.
* For example, rendering to an ARGB palette may load the RGB data and organise as
* an array of 32-bit integer ARGB values.
* </p>
*
* @param bytes representing the loaded palette data. Each colour is represented by three
* bytes, 1 each for red, green and blue.
* @param position that the colour values begin in bytes
* @param length number of bytes that the palette bytes continue. Guaranteed to be a
* multiple of 3.
* @throws PngException
*/
void processPalette(byte[] bytes, int position, int length) throws PngException;
/**
* Process one IDAT chunk the "default image" bitmap in a given file.
* <p>
* The "default", "main" or "primary image" of a file is what you'd generally
* think of as "the" image in file, namely the image constructed of IDAT chunks
* and displayed by any normal viewer or browser.
* <p>
* I theorise (without proof) that most PNG files in the wild have a single
* IDAT chunk representing all pixels in the main image, but the PNG specification
* requires loaders to handle the case where there are multiple IDAT chunks.
* In the case of multiple chunks the data continues precisely where the last
* IDAT chunk left off. The same applies to fdAT chunks.
* <p>
* The data is provided not in any pre-parsed object or even as a byte array because
* the bytes can be arbitrarily long. The InputStream provided is a slice of the
* containing source and will terminate at the end of the IDAT chunk.
*
* @param inputStream data source containing all bytes in the image data chunk.
* @param code of the chunk which should always be IDAT in this case.
* Compare to this.processFrameImageData.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
void processAnimationControl(PngAnimationControl animationControl) throws PngException;
void processFrameControl(PngFrameControl frameControl) throws PngException;
/**
* The reader has determined that bitmap data needs to be processed for an animation frame.
* The image data may be from an IDAT chunk or an fdAT chunk.
* <p>
* Whether the "default image" in a PNG file is to be part of the animation or discarded
* is determed by the placement of the first fcTL chunk in the file. See the APNG specification.
*
* @param inputStream data source containing all bytes in the frame image data chunk.
* @param code either IDAT or fdAT. Designed to allow an implementation to detect whether the
* frame is from the "main image" or a subsequent image. May not be necessary.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
/**
* A subclass can record data about the chunk here.
* <p>
* WARNING: this API may be removed. I'm not sure if it is useful.
*
* @param chunkMapItem represents a "map" of a single chunk in the file.
* @throws PngException
*/
void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException;
/**
* @return the result of processing all chunks. Only ever called after the file is fully read.
*/
ResultT getResult();
}
@@ -0,0 +1,134 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* Created by aellerton on 15/05/2015.
*/
public interface PngChunkReader<ResultT> extends PngReader<ResultT> {
@Override
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException;
void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException;
void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException;
/**
* Process the tRNS chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11tRNS:
* <p>
* The tRNS chunk specifies either alpha values that are associated with palette entries (for indexed-colour
* images) or a single transparent colour (for greyscale and truecolour images). The tRNS chunk contains:
* <p>
* Colour type 0:
* <p>
* Grey sample value 2 bytes
* <p>
* Colour type 2:
* <p>
* Red sample value 2 bytes
* Blue sample value 2 bytes
* Green sample value 2 bytes
* <p>
* Colour type 3:
* <p>
* Alpha for palette index 0 1 byte
* Alpha for palette index 1 1 byte
* ...etc... 1 byte
* <p>
* Note that for palette colour types the number of transparency entries may be less than the number of
* entries in the palette. In that case all missing entries are assumed to be fully opaque.
*
* @param source
* @param dataLength
*/
void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Process the bKGD chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11bKGD:
* <p>
* The bKGD chunk specifies a default background colour to present the image against.
* If there is any other preferred background, either user-specified or part of a larger
* page (as in a browser), the bKGD chunk should be ignored. The bKGD chunk contains:
* <p>
* Colour types 0 and 4
* Greyscale 2 bytes
* Colour types 2 and 6
* Red 2 bytes
* Green 2 bytes
* Blue 2 bytes
* Colour type 3
* Palette index 1 byte
* <p>
* For colour type 3 (indexed-colour), the value is the palette index of the colour to be used as background.
* <p>
* For colour types 0 and 4 (greyscale, greyscale with alpha), the value is the grey level to be used as
* background in the range 0 to (2bitdepth)-1. For colour types 2 and 6 (truecolour, truecolour with alpha),
* the values are the colour to be used as background, given as RGB samples in the range 0 to (2bitdepth)-1.
* In each case, for consistency, two bytes per sample are used regardless of the image bit depth. If the image
* bit depth is less than 16, the least significant bits are used and the others are 0.
*
* @param source
* @param dataLength
* @throws IOException
*/
void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException;
void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the IDAT chunk.
* <p>
* The default implementation skips the data, deferring to the finishedChunks() method
* to process the data. Key reasons to do this:
* <ul>
* <li>There could be multiple IDAT chunks and the streams need to
* be concatenated. This could be done in a single pass but it's more
* complicated and is not the objective of this implementation.</li>
* <li>This might be an APNG file and the IDAT chunk(s) are to be skipped.</li>
* </ul>
*
* @param source to read from
* @param dataLength in bytes of the data
* @throws PngException
* @throws IOException
*/
void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException;
void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the fcTL chunk.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
*
* @param source
* @param dataLength
* @throws IOException
* @throws PngException
*/
void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException;
void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Give subclasses the opportunity to process a chunk code that was not recognised.
*
* @param code chunk type as integer.
* @param source of PNG data, positioned to read the data bytes.
* @param dataPosition offset from absolute start of bytes that data beings.
* @param dataLength of this chunk
* @throws IOException
*/
void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException;
}
@@ -0,0 +1,112 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
/**
* PNG reading support functions.
*/
public class PngReadHelper {
/**
* Return true if the next 8 bytes of the InputStream match the
* standard 8 byte PNG Signature, and false if they do not match.
* <p>
* If the stream ends before the signature is read an EOFExceptoin is thrown.
* </p><p>
* Note that no temporary buffer is allocated.
* </p>
*
* @param is Stream to read the 8-byte PNG signature from
* @throws IOException in the case of any IO exception, including EOF.
*/
public static boolean readSignature(InputStream is) throws IOException {
for (int i = 0; i < PngConstants.LENGTH_SIGNATURE; i++) {
int b = is.read();
if (b < 0) {
throw new EOFException();
}
if ((byte) b != PngConstants.BYTES_SIGNATURE[i]) {
return false;
}
}
return true;
}
/**
* Reads a given InputStream using the PngReader to process all chunks until the file is
* finished, then returning the result from the PngReader.
*
* @param is stream to read
* @param reader reads and delegates processing of all chunks
* @param <ResultT> result of the processing
* @return result of the processing of the InputStream.
* @throws PngException
*/
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
try {
if (!PngReadHelper.readSignature(is)) {
throw new PngException(PngConstants.ERROR_NOT_PNG, "Failed to read PNG signature");
}
// PngAtOnceSource source = PngAtOnceSource.from(is);//, sourceName);
PngSource source = new PngStreamSource(is);
boolean finished = false;
while (!finished) {
int length = source.readInt();
int code = source.readInt();
finished = reader.readChunk(source, code, length);
}
if (source.available() > 0) { // Should trailing data after IEND always be error or can configure as warning?
throw new PngException(PngConstants.ERROR_EOF_EXPECTED, String.format("Completed IEND but %d byte(s) remain", source.available()));
}
reader.finishedChunks(source);
return reader.getResult();
} catch (EOFException e) {
throw new PngException(PngConstants.ERROR_EOF, "Unexpected EOF", e);
} catch (IOException e) {
throw new PngException(PngConstants.ERROR_UNKNOWN_IO_FAILURE, e.getMessage(), e);
}
}
/**
* Number of bytes per row is key to processing scanlines.
* <p>
* TODO: should this by on the header?
*/
public static int calculateBytesPerRow(int pixelsPerRow, byte bitDepth, PngColourType colourType, byte interlaceMethod) {
if (interlaceMethod != 0) {
throw new IllegalStateException("Interlaced images not yet supported");
} else {
int numComponentsPerPixel = colourType.componentsPerPixel; // or "channels". e.g. "gray and alpha" means two components.
int bitsPerComponent = bitDepth; // e.g. "4" means 4 bits for gray, 4 bits for alpha
int bitsPerPixel = bitsPerComponent * numComponentsPerPixel; // e.g. total of 8 bits per pixel
int bitsPerRow = bitsPerPixel * pixelsPerRow;
//?? use that (bitDepth+7)>>3 ... thing?
// unsigned int bpp = (row_info->pixel_depth + 7) >> 3; // libpng
// If there are less than 8 bits per pixel, then ensure the last byte of the row is padded.
int bytesPerRow = bitsPerRow / 8 + ((0 == (bitsPerRow % 8)) ? 0 : (8 - bitsPerRow % 8));
return 1 + bytesPerRow; // need 1 byte for filter code
}
}
private PngReadHelper() {
}
}
@@ -0,0 +1,20 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* All PngReader implementations need to read a specific single chunk and to return
* a result of some form.
*/
public interface PngReader<ResultT> {
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void finishedChunks(PngSource source) throws PngException, IOException;
ResultT getResult();
}
@@ -0,0 +1,69 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.FilterInputStream;
import java.io.InputStream;
/**
* A PngScanlineProcessor receives decompressed, de-filtered scanlines, one by one, in order
* from top to bottom. Interlaced scanlines are not yet supported. The scanline data is not
* parsed or expanded in any form, so if the PNG is 1-bit monochrome or 16 bit RGBA, the bytes
* are provided as-is. The job of the processor is to read and reformat each pixel as
* appropriate for the destination bitmap.
* <p>
* Note that a single instance of a PngScanlineProcessor is intended to process a single
* bitmap and that, when the bitmap is complete, the isFinished() method will return true.
* In the case of an animated PNG, it is expected that a new PngScanlineProcessor will be
* created to service the new bitmap.
* <p>
* Note: I wonder if this would be better named PngScanlineTransformer because its primary
* purpose is to convert pixels from raw file format to destination format.
*/
public interface PngScanlineProcessor {
/**
* A PngScanlineProcessor is responsible for decompressing the raw (compressed) data.
* This is important because there can be more than one IDAT and fdAT chunks for a single
* image, and decompression must occur seamlessly across those chunks, so the decompression
* must be stateful across multiple invocations of makeInflaterInputStream. In practice, it
* seems to be rare that there are multiple image data chunks for a single image.
*
* @param inputStream stream over raw compressed PNG image data.
* @return an InflaterInputStream that decompresses the current stream.
*/
FilterInputStream makeInflaterInputStream(InputStream inputStream);
/**
* The processScanline method is invoked when the raw image data has been first decompressed
* then de-filtered. The PngScanlineProcessor then must interpret each byte according to the
* specific image format and render it to the destination as appropriate.
*
* @param bytes decompressed, de-filtered bytes, ready for processing.
* @param position the position that scanline pixels begin in the array. Note that it is
* the responsibility of the PngScanlineProcessor to know exactly how many
* bytes are in the row. This is because a single processor might process
* different frames in an APNG, and each frame can have a different width
* (up to the maximum set in the PNG header).
*/
void processScanline(byte[] bytes, int position);
int getBytesPerLine();
void processTransparentGreyscale(byte k1, byte k0);
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0);
/**
* A PngScanlineProcessor must be able to decompress more than one consecutive IDAT or fdAT
* chunks. This is because the PNG specification states that a valid PNG file can have more
* than one consecutive IDAT (and by extension, fdAT) chunks and the data therein must be
* treated as if concatenated.
*
* @return true when the data for the current bitmap is complete.
*/
boolean isFinished();
//InflaterInputStream connect(InputStream inputStream);
}
@@ -0,0 +1,43 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* The API for reading any PNG source.
*
* @see PngAtOnceSource
* <p>
* WARNING: this may be removed in favour of direct use of InputStream objects.
*/
public interface PngSource {
int getLength();
boolean supportsByteAccess();
byte[] getBytes() throws IOException;
byte readByte() throws IOException;
short readUnsignedShort() throws IOException;
int readInt() throws IOException;
long skip(int chunkLength) throws IOException;
int tell();
int available();
// String getSourceDescription();
// ByteArrayInputStream getBis();
DataInputStream getDis();
InputStream slice(int dataLength) throws IOException;
}
@@ -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);
}
}
@@ -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;
}
}
@@ -0,0 +1,308 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipException;
/**
* A hacked copy of the Java standard java.util.zip.InflaterInputStream that
* is modified to work with files that have multiple IDAT (or fdAT) chunks for
* a single bitmap.
* <p>
* The default java.util.zip.InflaterInputStream by David Connelly works a treat
* except that if the java.util.zip.Inflater is expecting data when the stream
* runs out of data then the stream will die with an exception.
* <p>
* The PartialInflaterInputStream, on the other hand, quietly allows the EOF
* to be returned and leaves the Inflater in the "waiting for more data" data,
* ready to pick up when the next IDAT (or fdAT) chunk starts.
* <p>
* If you're wondering why I didn't just subclass InflaterInputStream, there are
* a few reasons. The main change is in the ``fill()`` method, which needs to
* call the method ``ensureOpen()`` but can't because it is private. It is also
* ideal to return an int but the original ``fill`` method is void. Then there
* is a change to the ``read()`` method and that references a number of private
* fields. In short, monkeying around in an attempt to reuse the original seemed
* more expensive than just copying the original code.
* <p>
* The original class documentation states:
* "This class implements a stream filter for uncompressing data in the
* "deflate" compression format. It is also used as the basis for other
* decompression filters, such as GZIPInputStream."
* </blockquote>
*
* @see InflaterInputStream
*/
public class PartialInflaterInputStream extends FilterInputStream {
/**
* Decompressor for this stream.
*/
protected Inflater inf;
/**
* Input buffer for decompression.
*/
protected byte[] buf;
/**
* Length of input buffer.
*/
protected int len;
private boolean closed = false;
// this flag is set to true after EOF has reached
private boolean reachEOF = false;
/**
* Check to make sure that this stream has not been closed
*/
private void ensureOpen() throws IOException {
if (closed) {
throw new IOException("Stream closed");
}
}
/**
* Creates a new input stream with the specified decompressor and
* buffer size.
*
* @param in the input stream
* @param inf the decompressor ("inflater")
* @param size the input buffer size
* @throws IllegalArgumentException if size is <= 0
*/
public PartialInflaterInputStream(InputStream in, Inflater inf, int size) {
super(in);
if (in == null || inf == null) {
throw new NullPointerException();
} else if (size <= 0) {
throw new IllegalArgumentException("buffer size <= 0");
}
this.inf = inf;
buf = new byte[size];
}
/**
* Creates a new input stream with the specified decompressor and a
* default buffer size.
*
* @param in the input stream
* @param inf the decompressor ("inflater")
*/
public PartialInflaterInputStream(InputStream in, Inflater inf) {
this(in, inf, 512);
}
boolean usesDefaultInflater = false;
/**
* Creates a new input stream with a default decompressor and buffer size.
*
* @param in the input stream
*/
public PartialInflaterInputStream(InputStream in) {
this(in, new Inflater());
usesDefaultInflater = true;
}
private byte[] singleByteBuf = new byte[1];
/**
* Reads a byte of uncompressed data. This method will block until
* enough input is available for decompression.
*
* @return the byte read, or -1 if end of compressed input is reached
* @throws IOException if an I/O error has occurred
*/
public int read() throws IOException {
ensureOpen();
return read(singleByteBuf, 0, 1) == -1 ? -1 : singleByteBuf[0] & 0xff;
}
/**
* Reads uncompressed data into an array of bytes. If <code>len</code> is not
* zero, the method will block until some input can be decompressed; otherwise,
* no bytes are read and <code>0</code> is returned.
*
* @param b the buffer into which the data is read
* @param off the start offset in the destination array <code>b</code>
* @param len the maximum number of bytes read
* @return the actual number of bytes read, or -1 if the end of the
* compressed input is reached or a preset dictionary is needed
* @throws NullPointerException If <code>b</code> is <code>null</code>.
* @throws IndexOutOfBoundsException If <code>off</code> is negative,
* <code>len</code> is negative, or <code>len</code> is greater than
* <code>b.length - off</code>
* @throws ZipException if a ZIP format error has occurred
* @throws IOException if an I/O error has occurred
*/
public int read(byte[] b, int off, int len) throws IOException {
ensureOpen();
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
try {
int n;
while ((n = inf.inflate(b, off, len)) == 0) {
if (inf.finished() || inf.needsDictionary()) {
reachEOF = true;
return -1;
}
if (inf.needsInput()) {
n = fill();
if (n <= 0) { // TODO: or only < 0?
return -1;
}
}
}
return n;
} catch (DataFormatException e) {
String s = e.getMessage();
throw new ZipException(s != null ? s : "Invalid ZLIB data format");
}
}
/**
* Returns 0 after EOF has been reached, otherwise always return 1.
* <p>
* Programs should not count on this method to return the actual number
* of bytes that could be read without blocking.
*
* @return 1 before EOF and 0 after EOF.
* @throws IOException if an I/O error occurs.
*
*/
public int available() throws IOException {
ensureOpen();
if (reachEOF) {
return 0;
} else {
return 1;
}
}
private byte[] b = new byte[512];
/**
* Skips specified number of bytes of uncompressed data.
*
* @param n the number of bytes to skip
* @return the actual number of bytes skipped.
* @throws IOException if an I/O error has occurred
* @throws IllegalArgumentException if n < 0
*/
public long skip(long n) throws IOException {
if (n < 0) {
throw new IllegalArgumentException("negative skip length");
}
ensureOpen();
int max = (int) Math.min(n, Integer.MAX_VALUE);
int total = 0;
while (total < max) {
int len = max - total;
if (len > b.length) {
len = b.length;
}
len = read(b, 0, len);
if (len == -1) {
reachEOF = true;
break;
}
total += len;
}
return total;
}
/**
* Closes this input stream and releases any system resources associated
* with the stream.
*
* @throws IOException if an I/O error has occurred
*/
public void close() throws IOException {
if (!closed) {
if (usesDefaultInflater)
inf.end();
in.close();
closed = true;
}
}
/**
* Fills input buffer with more data to decompress.
*
* @throws IOException if an I/O error has occurred
*/
protected int fill() throws IOException {
ensureOpen();
int n = in.read(buf, 0, buf.length);
/* A. Ellerton: remove the failure
if (len == -1) {
throw new EOFException("Unexpected end of ZLIB input stream");
}
inf.setInput(buf, 0, len);
*/
if (n > 0) {
len = n;
inf.setInput(buf, 0, len);
}
return n;
}
/**
* Tests if this input stream supports the <code>mark</code> and
* <code>reset</code> methods. The <code>markSupported</code>
* method of <code>InflaterInputStream</code> returns
* <code>false</code>.
*
* @return a <code>boolean</code> indicating if this stream type supports
* the <code>mark</code> and <code>reset</code> methods.
* @see InputStream#mark(int)
* @see InputStream#reset()
*/
public boolean markSupported() {
return false;
}
/**
* Marks the current position in this input stream.
*
* <p> The <code>mark</code> method of <code>InflaterInputStream</code>
* does nothing.
*
* @param readlimit the maximum limit of bytes that can be read before
* the mark position becomes invalid.
* @see InputStream#reset()
*/
public synchronized void mark(int readlimit) {
}
/**
* Repositions this stream to the position at the time the
* <code>mark</code> method was last called on this input stream.
*
* <p> The method <code>reset</code> for class
* <code>InflaterInputStream</code> does nothing except throw an
* <code>IOException</code>.
*
* @throws IOException if this method is invoked.
* @see InputStream#mark(int)
* @see IOException
*/
public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
}
@@ -0,0 +1,62 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import org.jackhuang.hmcl.ui.image.apng.chunks.*;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.util.ArrayList;
import java.util.List;
/**
* A PngContainer represents the parsed content of a PNG file.
* <p>
* The original idea was that all implementations can use this as a "container"
* for representing the data, but I think it is too generic to be useful.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngContainer {
public List<PngChunkMap> chunks = new ArrayList<>(4);
public PngHeader header;
public PngGamma gamma;
public PngPalette palette;
//PngTransparency transarency;
//PngBackground background;
public PngAnimationControl animationControl;
public List<PngFrameControl> animationFrames;
public PngFrameControl currentFrame;
public boolean hasDefaultImage = false;
public PngHeader getHeader() {
return header;
}
public PngGamma getGamma() {
return gamma;
}
public boolean isAnimated() {
return animationControl != null;// && animationControl.numFrames > 1;
}
/* TODO need?
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<PngFrameControl> getAnimationFrames() {
return animationFrames;
}
public PngFrameControl getCurrentAnimationFrame() throws PngException {
if (animationFrames.isEmpty()) {
throw new PngIntegrityException("No animation frames yet");
}
return animationFrames.get(animationFrames.size()-1);
}
*/
}
@@ -0,0 +1,21 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
/**
* Returns the PngContainer built by the PngContainerProcessor.
* WARNING: this may be removed.
*/
public class PngContainerBuilder extends PngContainerProcessor<PngContainer> {
// @Override
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
// return type;
// }
@Override
public PngContainer getResult() {
return getContainer();
}
}
@@ -0,0 +1,94 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.util;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.chunks.*;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
/**
* TODO: need to keep this?
*/
public abstract class PngContainerProcessor<ResultT> implements PngChunkProcessor<ResultT> {
protected final PngContainer container;
public PngContainerProcessor() {
this.container = new PngContainer();
}
@Override
public void processHeader(PngHeader header) throws PngException {
container.header = header;
}
@Override
public void processGamma(PngGamma pngGamma) {
container.gamma = pngGamma;
}
@Override
public void processPalette(byte[] bytes, int position, int length) throws PngException {
container.palette = PngPalette.from(bytes, position, length);
}
@Override
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
// NOP
}
@Override
public void processAnimationControl(PngAnimationControl animationControl) {
container.animationControl = animationControl;
container.animationFrames = new ArrayList<PngFrameControl>(container.animationControl.numFrames);
}
@Override
public void processFrameControl(PngFrameControl pngFrameControl) throws PngException {
container.animationFrames.add(pngFrameControl);
container.currentFrame = pngFrameControl;
// imageDataProcessor = builder.makeFrameImageProcessor(container.currentFrame);
}
@Override
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (null != container.currentFrame) {
throw new IllegalStateException("Attempt to process main frame image data but an animation frame is in place");
}
container.hasDefaultImage = true;
// TODO: keep this?
processImageDataStream(inputStream);
}
@Override
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (null == container.currentFrame) {
throw new IllegalStateException("Attempt to process animation frame image data without a frame in place");
}
container.currentFrame.appendImageData(new PngChunkMap(code, position, length, 0));
// TODO: keep this?
processImageDataStream(inputStream);
}
protected void processImageDataStream(InputStream inputStream) throws IOException, PngException {
inputStream.skip(inputStream.available());
}
@Override
public void processChunkMapItem(PngChunkMap pngChunkMap) {
container.chunks.add(pngChunkMap);
}
public PngContainer getContainer() {
return container;
}
}
@@ -0,0 +1,115 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image.internal;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.value.WritableValue;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.image.AnimationImage;
import java.lang.ref.WeakReference;
/**
* @author Glavo
*/
public final class AnimationImageImpl extends WritableImage implements AnimationImage {
private Animation animation;
private final int[][] frames;
private final int[] durations;
private final int cycleCount;
public AnimationImageImpl(int width, int height,
int[][] frames, int[] durations, int cycleCount) {
super(width, height);
if (frames.length != durations.length) {
throw new IllegalArgumentException("frames.length != durations.length");
}
this.frames = frames;
this.durations = durations;
this.cycleCount = cycleCount;
play();
}
public void play() {
if (animation == null) {
animation = new Animation(this);
animation.timeline.play();
}
}
private void updateImage(int frameIndex) {
final int width = (int) getWidth();
final int height = (int) getHeight();
final int[] frame = frames[frameIndex];
this.getPixelWriter().setPixels(0, 0,
width, height,
PixelFormat.getIntArgbInstance(),
frame, 0, width
);
}
private static final class Animation implements WritableValue<Integer> {
private final Timeline timeline = new Timeline();
private final WeakReference<AnimationImageImpl> imageRef;
private Integer value;
private Animation(AnimationImageImpl image) {
this.imageRef = new WeakReference<>(image);
timeline.setCycleCount(image.cycleCount);
int duration = 0;
for (int i = 0; i < image.frames.length; ++i) {
timeline.getKeyFrames().add(
new KeyFrame(Duration.millis(duration),
new KeyValue(this, i, Interpolator.DISCRETE)));
duration = duration + image.durations[i];
}
timeline.getKeyFrames().add(new KeyFrame(Duration.millis(duration)));
}
@Override
public Integer getValue() {
return value;
}
@Override
public void setValue(Integer value) {
this.value = value;
AnimationImageImpl image = imageRef.get();
if (image == null) {
timeline.stop();
return;
}
image.updateImage(value);
}
}
}
@@ -19,21 +19,22 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXSlider;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.effects.JFXDepthManager;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.binding.When;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
@@ -56,6 +57,20 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class PersonalizationPage extends StackPane {
private static int snapOpacity(double val) {
if (val <= 0) {
return 0;
} else if (Double.isNaN(val) || val >= 100.) {
return 100;
}
int prevTick = (int) (val / 5);
int prevTickValue = prevTick * 5;
int nextTickValue = (prevTick + 1) * 5;
return (val - prevTickValue) > (nextTickValue - val) ? nextTickValue : prevTickValue;
}
public PersonalizationPage() {
VBox content = new VBox(10);
content.setPadding(new Insets(10));
@@ -112,14 +127,15 @@ public class PersonalizationPage extends StackPane {
new MultiFileItem.Option<>(i18n("launcher.background.default"), EnumBackgroundImage.DEFAULT)
.setTooltip(i18n("launcher.background.default.tooltip")),
new MultiFileItem.Option<>(i18n("launcher.background.classic"), EnumBackgroundImage.CLASSIC),
new MultiFileItem.Option<>(i18n("launcher.background.translucent"), EnumBackgroundImage.TRANSLUCENT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumBackgroundImage.CUSTOM)
.setChooserTitle(i18n("launcher.background.choose"))
.addExtensionFilter(FXUtils.getImageExtensionFilter())
.bindBidirectional(config().backgroundImageProperty()),
new MultiFileItem.StringOption<>(i18n("launcher.background.network"), EnumBackgroundImage.NETWORK)
.setValidators(new URLValidator(true))
.bindBidirectional(config().backgroundImageUrlProperty())
.bindBidirectional(config().backgroundImageUrlProperty()),
new MultiFileItem.PaintOption<>(i18n("launcher.background.paint"), EnumBackgroundImage.PAINT)
.bindBidirectional(config().backgroundPaintProperty())
));
backgroundItem.selectedDataProperty().bindBidirectional(config().backgroundImageTypeProperty());
backgroundSublist.subtitleProperty().bind(
@@ -127,7 +143,49 @@ public class PersonalizationPage extends StackPane {
.then(i18n("launcher.background.default"))
.otherwise(config().backgroundImageProperty()));
componentList.getContent().add(backgroundItem);
HBox opacityItem = new HBox(8);
{
opacityItem.setAlignment(Pos.CENTER);
Label label = new Label(i18n("settings.launcher.background.settings.opacity"));
JFXSlider slider = new JFXSlider(0, 100,
config().getBackgroundImageType() != EnumBackgroundImage.TRANSLUCENT
? config().getBackgroundImageOpacity() : 50);
slider.setShowTickMarks(true);
slider.setMajorTickUnit(10);
slider.setMinorTickCount(1);
slider.setBlockIncrement(5);
slider.setSnapToTicks(true);
HBox.setHgrow(slider, Priority.ALWAYS);
if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) {
slider.setDisable(true);
config().backgroundImageTypeProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends EnumBackgroundImage> observable, EnumBackgroundImage oldValue, EnumBackgroundImage newValue) {
if (newValue != EnumBackgroundImage.TRANSLUCENT) {
config().backgroundImageTypeProperty().removeListener(this);
slider.setDisable(false);
slider.setValue(100);
}
}
});
}
Label textOpacity = new Label();
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
textOpacity.textProperty().bind(valueBinding);
slider.setValueFactory(s -> valueBinding);
slider.valueProperty().addListener((observable, oldValue, newValue) ->
config().setBackgroundImageOpacity(snapOpacity(newValue.doubleValue())));
opacityItem.getChildren().setAll(label, slider, textOpacity);
}
componentList.getContent().setAll(backgroundItem, opacityItem);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("launcher.background")), componentList);
}
@@ -18,8 +18,9 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Locale;
@@ -52,7 +53,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
private final MultiFileItem.FileOption<NativesDirectoryType> nativesDirCustomOption;
private final JFXComboBox<Renderer> cboRenderer;
public AdvancedVersionSettingPage(Profile profile, String versionId, VersionSetting versionSetting) {
public AdvancedVersionSettingPage(Profile profile, @Nullable String versionId, VersionSetting versionSetting) {
this.profile = profile;
this.versionId = versionId;
this.versionSetting = versionSetting;
@@ -239,8 +240,27 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
useNativeOpenALPane.selectedProperty().bindBidirectional(versionSetting.useNativeOpenALProperty());
nativesDirItem.selectedDataProperty().bindBidirectional(versionSetting.nativesDirTypeProperty());
nativesDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> Paths.get(profile.getRepository().getRunDirectory(versionId).getAbsolutePath() + "/natives").normalize().toString(),
versionSetting.nativesDirProperty(), versionSetting.nativesDirTypeProperty()));
nativesDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> {
if (versionSetting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
String nativesDirName = "natives-" + Platform.SYSTEM_PLATFORM;
if (versionId == null) {
return String.format("%s/%s/%s",
profile.getRepository().getBaseDirectory().toPath().resolve("versions").toAbsolutePath().normalize(),
i18n("settings.advanced.natives_directory.default.version_id"),
nativesDirName
);
} else {
return profile.getRepository().getVersionRoot(versionId).toPath()
.toAbsolutePath().normalize()
.resolve(nativesDirName)
.toString();
}
} else if (versionSetting.getNativesDirType() == NativesDirectoryType.CUSTOM) {
return versionSetting.getNativesDir();
} else {
return null;
}
}, versionSetting.nativesDirProperty(), versionSetting.nativesDirTypeProperty()));
}
void unbindProperties() {
@@ -54,7 +54,6 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -180,7 +179,7 @@ public class DownloadPage extends Control implements DecoratorPage {
Controllers.taskDialog(
Task.composeAsync(() -> {
var task = new FileDownloadTask(URI.create(file.getFile().getUrl()), dest.toPath(), file.getFile().getIntegrityCheck());
var task = new FileDownloadTask(file.getFile().getUrl(), dest.toPath(), file.getFile().getIntegrityCheck());
task.setName(file.getName());
return task;
}),
@@ -20,12 +20,15 @@ package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXRadioButton;
import javafx.geometry.Bounds;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Screen;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -35,11 +38,13 @@ import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.Lazy;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListItemSkin extends SkinBase<GameListItem> {
private static GameListItem currentSkinnable;
private static Lazy<JFXPopup> popup = new Lazy<>(() -> {
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
@@ -99,7 +104,9 @@ public class GameListItemSkin extends SkinBase<GameListItem> {
JFXButton btnManage = new JFXButton();
btnManage.setOnAction(e -> {
currentSkinnable = skinnable;
popup.get().show(root, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root);
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
btnManage.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnManage, Pos.CENTER);
@@ -124,8 +131,46 @@ public class GameListItemSkin extends SkinBase<GameListItem> {
}
} else if (e.getButton() == MouseButton.SECONDARY) {
currentSkinnable = skinnable;
popup.get().show(root, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, e.getX(), e.getY());
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root);
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
/**
* Intelligently determines the popup position to prevent the menu from exceeding screen boundaries.
* Supports multi-monitor setups by detecting the current screen where the component is located.
*
* @param root the root node to calculate position relative to
* @return the optimal vertical position for the popup menu
*/
private static JFXPopup.PopupVPosition determineOptimalPopupPosition(BorderPane root) {
// Get the screen bounds in screen coordinates
Bounds screenBounds = root.localToScreen(root.getBoundsInLocal());
// Convert Bounds to Rectangle2D for getScreensForRectangle method
Rectangle2D boundsRect = new Rectangle2D(
screenBounds.getMinX(), screenBounds.getMinY(),
screenBounds.getWidth(), screenBounds.getHeight()
);
// Find the screen that contains this component (supports multi-monitor)
List<Screen> screens = Screen.getScreensForRectangle(boundsRect);
Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0);
Rectangle2D visualBounds = currentScreen.getVisualBounds();
double screenHeight = visualBounds.getHeight();
double screenMinY = visualBounds.getMinY();
double itemScreenY = screenBounds.getMinY();
// Calculate available space relative to the current screen
double availableSpaceAbove = itemScreenY - screenMinY;
double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getHeight();
double menuHeight = popup.get().getPopupContent().getHeight();
return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight)
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
}
}
@@ -62,8 +62,8 @@ import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -249,23 +249,30 @@ class ModListPageSkin extends SkinBase<ModListPage> {
} else {
listView.getItems().clear();
Predicate<String> predicate;
Predicate<@Nullable String> predicate;
if (queryString.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(queryString.substring("regex:".length()));
predicate = s -> pattern.matcher(s).find();
predicate = s -> s != null && pattern.matcher(s).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = queryString.toLowerCase(Locale.ROOT);
predicate = s -> s.toLowerCase(Locale.ROOT).contains(lowerQueryString);
predicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerQueryString);
}
// Do we need to search in the background thread?
for (ModInfoObject item : getSkinnable().getItems()) {
if (predicate.test(item.getModInfo().getFileName())) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getMod() != null ? item.getMod().getDisplayName() : null))) {
listView.getItems().add(item);
}
}
@@ -333,8 +340,8 @@ class ModListPageSkin extends SkinBase<ModListPage> {
if (StringUtils.isNotBlank(logoPath)) {
Path iconPath = fs.getPath(logoPath);
if (Files.exists(iconPath)) {
try (InputStream stream = Files.newInputStream(iconPath)) {
Image image = new Image(stream, 40, 40, true, true);
try {
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
} catch (Throwable e) {
@@ -367,11 +374,9 @@ class ModListPageSkin extends SkinBase<ModListPage> {
for (String path : defaultPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
try (InputStream stream = Files.newInputStream(iconPath)) {
Image image = new Image(stream, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
}
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
}
}
} catch (Exception e) {
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.ui.versions;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
@@ -121,9 +123,11 @@ public enum ModTranslations {
return true;
}
try {
String modData = IOUtils.readFullyAsString(ModTranslations.class.getResourceAsStream(resourceName));
mods = Arrays.stream(modData.split("\n")).filter(line -> !line.startsWith("#")).map(Mod::new).collect(Collectors.toList());
//noinspection DataFlowIssue
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8))) {
mods = reader.lines().filter(line -> !line.startsWith("#")).map(Mod::new).collect(Collectors.toList());
return true;
} catch (Exception e) {
LOG.warning("Failed to load " + resourceName, e);
@@ -45,7 +45,6 @@ import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CSVTable;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
@@ -294,7 +293,7 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
fileName += ModManager.DISABLED_EXTENSION;
var task = new FileDownloadTask(
URI.create(remote.getFile().getUrl()),
remote.getFile().getUrl(),
modManager.getModsDirectory().resolve(fileName));
task.setName(remote.getName());
@@ -32,9 +32,6 @@ import javafx.scene.control.Toggle;
import javafx.scene.layout.*;
import javafx.scene.text.Text;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameDirectoryType;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ProcessPriority;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.setting.*;
@@ -40,12 +40,12 @@ import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
@@ -75,9 +75,9 @@ public final class Versions {
Path modpack;
URI downloadURL;
try {
downloadURL = NetworkUtils.toURI(file.getFile().getUrl());
modpack = Files.createTempFile("modpack", ".zip");
downloadURL = new URI(file.getFile().getUrl());
} catch (IOException | URISyntaxException e) {
} catch (IOException | IllegalArgumentException e) {
Controllers.dialog(
i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
@@ -23,7 +23,6 @@ import org.tukaani.xz.XZInputStream;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.jar.JarOutputStream;
@@ -33,7 +32,7 @@ final class HMCLDownloadTask extends FileDownloadTask {
private final RemoteVersion.Type archiveFormat;
public HMCLDownloadTask(RemoteVersion version, Path target) {
super(URI.create(version.getUrl()), target, version.getIntegrityCheck());
super(version.getUrl(), target, version.getIntegrityCheck());
archiveFormat = version.getType();
}
@@ -26,14 +26,13 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
public class RemoteVersion {
public static RemoteVersion fetch(UpdateChannel channel, String url) throws IOException {
try {
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(URI.create(url)), JsonObject.class);
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
String jarUrl = Optional.ofNullable(response.get("jar")).map(JsonElement::getAsString).orElse(null);
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
@@ -93,7 +93,7 @@ public final class AggregatedObservableList<T> {
/**
* Get the start index of this list inside the aggregated List.
* This is a private function. we can safely asume, that the list is in the map.
* This is a private function. we can safely assume, that the list is in the map.
*
* @param list the list in question
* @return the start index of this list in the aggregated List
@@ -114,7 +114,7 @@ public final class AggregatedObservableList<T> {
/**
* Get the end index of this list inside the aggregated List.
* This is a private function. we can safely asume, that the list is in the map.
* This is a private function. we can safely assume, that the list is in the map.
*
* @param list the list in question
* @param startIndex the start of the list (retrieve with {@link #getStartIndex(ObservableList)}
@@ -0,0 +1,37 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url;
import org.jackhuang.hmcl.util.url.data.DataURLHandle;
import java.net.URLStreamHandler;
import java.net.spi.URLStreamHandlerProvider;
/**
* @author Glavo
*/
public final class HMCLURLStreamHandlerProvider extends URLStreamHandlerProvider {
@Override
public URLStreamHandler createURLStreamHandler(String protocol) {
switch (protocol) {
case "data":
return new DataURLHandle();
}
return null;
}
}
@@ -0,0 +1,63 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url.data;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
/**
* @author Glavo
*/
public final class DataURLConnection extends URLConnection {
private final DataUri dataUri;
private byte[] data;
private InputStream inputStream;
DataURLConnection(URL url, DataUri dataUri) {
super(url);
this.dataUri = dataUri;
}
@Override
public void connect() throws IOException {
if (!connected) {
connected = true;
data = dataUri.readBytes();
inputStream = new ByteArrayInputStream(this.data);
}
}
@Override
public InputStream getInputStream() throws IOException {
connect();
return inputStream;
}
@Override
public String getContentType() {
return dataUri.getMediaType();
}
@Override
public long getContentLengthLong() {
return data != null ? data.length : -1;
}
}
@@ -0,0 +1,39 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url.data;
import java.io.IOException;
import java.net.*;
/**
* @author Glavo
*/
public final class DataURLHandle extends URLStreamHandler {
@Override
protected URLConnection openConnection(URL u) throws IOException {
try {
URI uri = u.toURI();
if (!DataUri.isDataUri(uri))
throw new MalformedURLException("URI is not a data URI: " + u);
return new DataURLConnection(u, new DataUri(uri));
} catch (URISyntaxException e) {
throw new MalformedURLException(e.getMessage());
}
}
}
@@ -0,0 +1,115 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url.data;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.Base64;
/**
* @author Glavo
*/
public final class DataUri {
public static final String SCHEME = "data";
private static IllegalArgumentException invalidUri(URI uri) {
return new IllegalArgumentException("Invalid data URI: " + uri);
}
public static boolean isDataUri(URI uri) {
return uri != null && SCHEME.equals(uri.getScheme());
}
private final @NotNull String mediaType;
private final @NotNull Charset charset;
private final boolean base64;
private final @NotNull String rawData;
public DataUri(URI uri) {
if (!uri.getScheme().equals(SCHEME)) {
throw new IllegalArgumentException("URI scheme must be " + SCHEME);
}
String schemeSpecificPart = uri.getSchemeSpecificPart();
if (schemeSpecificPart == null)
throw invalidUri(uri);
int comma = schemeSpecificPart.indexOf(',');
if (comma < 0)
throw invalidUri(uri);
String mediaType = schemeSpecificPart.substring(0, comma);
boolean base64 = mediaType.endsWith(";base64");
if (base64)
mediaType = mediaType.substring(0, mediaType.length() - ";base64".length());
this.mediaType = mediaType.trim();
this.charset = NetworkUtils.getCharsetFromContentType(mediaType);
this.base64 = base64;
this.rawData = schemeSpecificPart.substring(comma + 1);
}
public @NotNull String getMediaType() {
return mediaType;
}
public @NotNull Charset getCharset() {
return charset;
}
public boolean isBase64() {
return base64;
}
public @NotNull String getRawData() {
return rawData;
}
public byte[] readBytes() throws IOException {
if (base64) {
try {
return Base64.getDecoder().decode(rawData);
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
} else {
return rawData.getBytes(charset);
}
}
public String readString() throws IOException {
if (base64) {
try {
return new String(Base64.getDecoder().decode(rawData), charset);
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
} else {
return rawData;
}
}
@Override
public String toString() {
return String.format("DataUri{mediaType='%s', charset=%s, base64=%s, body='%s'}", mediaType, charset, base64, rawData);
}
}
@@ -0,0 +1 @@
org.jackhuang.hmcl.util.url.HMCLURLStreamHandlerProvider
@@ -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"
}
]
@@ -106,14 +106,26 @@
-fx-background-color: -fx-base-darker-color;
}
.memory-used:disabled {
-fx-opacity: 0.4;
}
.memory-allocate {
-fx-background-color: derive(-fx-base-color, 100%);
}
.memory-allocate:disabled {
-fx-opacity: 0.4;
}
.memory-total {
-fx-background-color: -fx-base-rippler-color;
}
.memory-total:disabled {
-fx-opacity: 0.4;
}
.update-label {
-fx-text-fill: red;
}
@@ -714,7 +714,7 @@ install.installer.external_version=%s (Installed by external process, which cann
install.installing=Installing
install.modpack=Install Modpack
install.modpack.installation=Modpack Installation
install.name.invalid=The name contains non-ASCII characters (such as emoji or CJK characters).\nIt is recommended to change the name to include only English letters, numbers, and underscores to avoid potential issues when launching the game.\nDo you want to proceed with the installation?
install.name.invalid=The name contains special characters (such as emoji or CJK characters).\nIt is recommended to change the name to include only English letters, numbers, and underscores to avoid potential issues when launching the game.\nDo you want to proceed with the installation?
install.new_game=Install Instance
install.new_game.already_exists=This instance name already exists. Please use another name.
install.new_game.current_game_version=Current Instance Version
@@ -812,7 +812,7 @@ launcher.background.classic=Classic
launcher.background.default=Default
launcher.background.default.tooltip=Or "background.png/.jpg/.gif/.webp" and the images in the "bg" directory
launcher.background.network=From URL
launcher.background.translucent=Translucent
launcher.background.paint=Solid Color
launcher.cache_directory=Cache Directory
launcher.cache_directory.clean=Clear Cache
launcher.cache_directory.choose=Choose cache directory
@@ -904,6 +904,7 @@ modpack.type.manual.warning=The modpack is manually packaged by the publisher, w
modpack.type.mcbbs=MCBBS
modpack.type.mcbbs.export=Can be imported by Hello Minecraft! Launcher
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=Can be imported by popular third-party launchers
modpack.type.multimc=MultiMC
modpack.type.multimc.export=Can be imported by Hello Minecraft! Launcher and MultiMC
modpack.type.server=Auto-Update Modpack from Server
@@ -921,7 +922,10 @@ modpack.wizard.step.3.title=Choose modpack type you wanted to export as.
modpack.wizard.step.initialization.exported_version=Game Instance to Export
modpack.wizard.step.initialization.force_update=Force updating the modpack to the latest version (you will need a file-hosting server)
modpack.wizard.step.initialization.include_launcher=Include the launcher
modpack.wizard.step.initialization.modrinth.info=The launcher will match CurseForge/Modrinth remote resources instead of local files (including mods, resource packs, and shader packs) during modpack creation to reduce the modpack size, and mark files with ".disabled" extension as optional for installation.
modpack.wizard.step.initialization.no_create_remote_files=Do not match remote files
modpack.wizard.step.initialization.save=Export to...
modpack.wizard.step.initialization.skip_curseforge_remote_files=Do not match CurseForge remote resources
modpack.wizard.step.initialization.warning=Before making a modpack, please make sure the game can be launched normally and Minecraft is a release version instead of a snapshot. The launcher will save your download settings.\n\
\n\
Keep in mind that you are not allowed to add mods and resource packs that explicitly state they could not to be distributed or put in a modpack.
@@ -1258,6 +1262,7 @@ settings.advanced.natives_directory=Native Library Path
settings.advanced.natives_directory.choose=Choose the location of the desired native library
settings.advanced.natives_directory.custom=Custom
settings.advanced.natives_directory.default=Default
settings.advanced.natives_directory.default.version_id=<Instance Name>
settings.advanced.natives_directory.hint=This option is intended only for users of Apple silicon or other not officially supported platforms. Please do not edit this option unless you know what you are doing.\n\
\n\
Before proceeding, please make sure all libraries (e.g. lwjgl.dll, libopenal.so) are provided in your desired directory.\n\
@@ -1353,6 +1358,7 @@ settings.launcher.theme=Theme
settings.launcher.title_transparent=Transparent Titlebar
settings.launcher.turn_off_animations=Disable Animation (Applies After Restart)
settings.launcher.version_list_source=Version List
settings.launcher.background.settings.opacity=Opacity
settings.memory=Memory
settings.memory.allocate.auto=%1$.1f GiB Minimum / %2$.1f GiB Allocated
@@ -124,7 +124,7 @@ account.methods.microsoft.purchase=Comprar Minecraft
account.methods.microsoft.snapshot=Está utilizando una versión no oficial de HMCL. Por favor, descargue la versión oficial para iniciar sesión.
account.methods.microsoft.snapshot.website=Sitio web oficial
account.methods.offline=Sin conexión
account.methods.offline.name.special_characters=Se recomienda utilizar letras en inglés, números y guiones bajos
account.methods.offline.name.special_characters=Utilice solo letras, números y guiones bajos (máximo 16 caracteres)
account.methods.offline.name.invalid.tip=Se recomienda utilizar sólo letras en inglés, números y guiones bajos para el nombre de usuario, y la longitud no debe superar los 16 caracteres.
account.methods.offline.name.invalid=Se recomienda utilizar sólo letras en inglés, números y guiones bajos para el nombre de usuario, y la longitud no debe superar los 16 caracteres.\n\
\n\
@@ -488,7 +488,7 @@ game.crash.reason.graphics_driver=El juego se ha colgado debido a un problema co
Si tu ordenador tiene una tarjeta gráfica discreta, tienes que comprobar si el juego utiliza gráficos integrados/núcleo. Si es así, inicie el launcher utilizando su tarjeta gráfica discreta. Si el problema persiste, probablemente debería considerar la posibilidad de adquirir una nueva tarjeta gráfica o un nuevo ordenador.\n\
\n\
Si utiliza su tarjeta gráfica integrada, tenga en cuenta que Minecraft 1.16.5 o anterior requiere <a href="https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html">Java 1.8.0_51 o más antiguo</a> para la serie de procesadores Intel(R) Core(TM) 3000 o anteriores.
game.crash.reason.macos_failed_to_find_service_port_for_display=El juego no puede continuar debido a un fallo al inicializar la ventana OpenGL en la plataforma Apple silicon.\n\
game.crash.reason.macos_failed_to_find_service_port_for_display=El juego no puede continuar debido a un fallo al inicializar la ventana OpenGL en la plataforma con chips de Apple.\n\
\n\
Para este problema, HMCL no tiene soluciones directas por el momento. Intente abrir cualquier navegador y ponerlo en pantalla completa, luego vuelva a HMCL, inicie el juego y <b>vuelva rápidamente a la página del navegador</b> antes de que aparezca la ventana del juego, espere a que aparezca la ventana del juego y luego vuelva a la ventana del juego.
game.crash.reason.illegal_access_error=El juego se ha colgado por culpa de algún(os) mod(s).\n\
@@ -817,7 +817,7 @@ launcher.background.classic=Clásico
launcher.background.default=Por defecto
launcher.background.default.tooltip=O «background.png/.jpg/.gif/.webp» y las imágenes en el directorio «bg».
launcher.background.network=Desde la URL
launcher.background.translucent=Translúcido
launcher.background.paint=Color sólido
launcher.cache_directory=Directorio de la caché
launcher.cache_directory.clean=Borrar caché
launcher.cache_directory.choose=Elegir el directorio de la caché
@@ -909,6 +909,7 @@ modpack.type.manual.warning=Este archivo contiene una copia completa de una inst
modpack.type.mcbbs=Tipo MCBBS
modpack.type.mcbbs.export=Puede ser importado por Hello Minecraft! Launcher
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=Puede ser importado por los principales lanzadores de terceros
modpack.type.multimc=MultiMC
modpack.type.multimc.export=Puede ser importado por Hello Minecraft! Launcher y MultiMC
modpack.type.server=Actualización automática del modpack desde el servidor
@@ -926,7 +927,10 @@ modpack.wizard.step.3.title=Elija el tipo de modpack que desea exportar.
modpack.wizard.step.initialization.exported_version=Instancia del juego a exportar
modpack.wizard.step.initialization.force_update=Forzar la actualización del modpack a la última versión (necesitarás un servicio de alojamiento de archivos)
modpack.wizard.step.initialization.include_launcher=Incluir el launcher
modpack.wizard.step.initialization.modrinth.info=El lanzador comparará los recursos remotos de CurseForge/Modrinth en lugar de los archivos locales (incluidos mods, paquetes de recursos y paquetes de shaders) durante la creación del modpack para reducir su tamaño, y marcará los archivos con extensión «.disabled» como opcionales para la instalación.
modpack.wizard.step.initialization.no_create_remote_files=No utilice recursos remotos para sustituir archivos locales
modpack.wizard.step.initialization.save=Exportar a...
modpack.wizard.step.initialization.skip_curseforge_remote_files=No utilice los recursos remotos de CurseForge para sustituir archivos locales
modpack.wizard.step.initialization.warning=Antes de hacer un modpack, por favor asegúrate de que el juego puede ser lanzado normalmente y Minecraft es una versión de lanzamiento en lugar de una snapshot. El launcher guardará tu configuración de descarga.\n\
\n\
Ten en cuenta que no se te permite añadir mods y paquetes de recursos que se digan explícitamente que no se pueden distribuir o poner en un modpack.
@@ -1263,7 +1267,8 @@ settings.advanced.natives_directory=Ruta de la biblioteca nativa
settings.advanced.natives_directory.choose=Elija la ubicación de la biblioteca nativa deseada
settings.advanced.natives_directory.custom=Personalizado
settings.advanced.natives_directory.default=Por defecto
settings.advanced.natives_directory.hint=Esta opción está pensada sólo para usuarios de Apple M1 u otras plataformas no soportadas oficialmente. Por favor, no modifique esta opción a menos que sepa lo que está haciendo.\n\
settings.advanced.natives_directory.default.version_id=<Nombre de la instancia>
settings.advanced.natives_directory.hint=Esta opción está pensada sólo para usuarios de chips de Apple u otras plataformas no soportadas oficialmente. Por favor, no modifique esta opción a menos que sepa lo que está haciendo.\n\
\n\
Antes de proceder, por favor, asegúrese de que todas las bibliotecas (por ejemplo, lwjgl.dll, libopenal.so) se proporcionan en su directorio deseado.
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
@@ -1331,6 +1336,10 @@ settings.launcher.download_source=Fuente de descarga
settings.launcher.download_source.auto=Elegir automáticamente el espejo de descarga
settings.launcher.enable_game_list=Mostrar lista de versiones en la página de inicio
settings.launcher.font=Fuente
settings.launcher.font.anti_aliasing=Suavizado de bordes
settings.launcher.font.anti_aliasing.auto=Automático
settings.launcher.font.anti_aliasing.gray=Escala de grises
settings.launcher.font.anti_aliasing.lcd=Subpíxel
settings.launcher.general=General
settings.launcher.language=Idioma
settings.launcher.launcher_log.export=Exportar registros del launcher
@@ -1353,6 +1362,7 @@ settings.launcher.theme=Tema
settings.launcher.title_transparent=Barra de título transparente
settings.launcher.turn_off_animations=Desactivar animación (Se aplica después de reiniciar)
settings.launcher.version_list_source=Lista de versiones
settings.launcher.background.settings.opacity=Opacidad
settings.memory=Memoria
settings.memory.allocate.auto=%1$.1f GiB Mínimo / %2$.1f GiB Asignado
@@ -540,7 +540,6 @@ launcher.background.choose=背景画像ファイルを選択してください
launcher.background.default=標準
launcher.background.default.tooltip=ランチャーと同じディレクトリにある background.png/.jpg/.gif/.webp と bg フォルダから自動的に画像を取得します。
launcher.background.network=ネットワーク
launcher.background.translucent=半透明
launcher.cache_directory=キャッシュ用のディレクトリ
launcher.cache_directory.clean=クリアランス
launcher.cache_directory.choose=キャッシュするディレクトリを選択します
@@ -624,6 +623,8 @@ modpack.type.curse.not_found=必要なリソースの一部が欠落している
modpack.type.manual.warning=このmodpackをインポートする代わりに、おそらくこのmodpackファイルを直接解凍する必要があります。そして、バンドルされたランチャーを使用してゲームを起動します。このmodpackは、ランチャーによってエクスポートされるのではなく、.minecraftディレクトリを圧縮することによって手動で作成されます。HMCLはこのmodpackのインポートを試みることができます、続行しますか?
modpack.type.mcbbs=MCBBS標準
modpack.type.mcbbs.export=Hello Minecraftでインポートできます!ランチャー
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=メインストリームのサードパーティ製イニシエータでインポート可能
modpack.type.multimc=MultiMC
modpack.type.multimc.export=Hello Minecraftでインポートできます!ランチャーとMultiMC
modpack.type.server=サーバー自動更新Modpack
@@ -641,7 +642,10 @@ modpack.wizard.step.3.title=modpackの形式を選択します。
modpack.wizard.step.initialization.exported_version=エクスポートされたゲームバージョン
modpack.wizard.step.initialization.force_update=可能であればmodpackを強制的に更新します
modpack.wizard.step.initialization.include_launcher=ランチャーを含める
modpack.wizard.step.initialization.modrinth.info=モッドパック作成プロセスにおいて、CurseForge/Modrinth のリモートリソースと一致するローカルファイルを置換して容量を削減し、.disabledサフィックス付きファイルをインストール時オプションとしてマーク
modpack.wizard.step.initialization.no_create_remote_files=リモートファイルとの一致を行わない
modpack.wizard.step.initialization.save=エクスポート先...
modpack.wizard.step.initialization.skip_curseforge_remote_files=CurseForge リモートリソースのマッチングをスキップ
modpack.wizard.step.initialization.warning=modpackを作成する前に、ゲームが正常に起動できること、および\nMinecraftがリリースバージョンであることを確認する必要があります。\n再配布できないmodを追加しないでください。
modpack.wizard.step.initialization.server=サーバーの自動更新modpackの詳細については、ここをクリックしてください
@@ -791,7 +795,7 @@ settings.advanced.natives_directory=ネイティブライブラリパス(例
settings.advanced.natives_directory.choose=ネイティブライブラリパスを選択してください
settings.advanced.natives_directory.custom=Custom(ゲームが実行時に必要とするネイティブライブラリを提供する必要があります)
settings.advanced.natives_directory.default=Standard(ネイティブライブラリは当社が提供します)
settings.advanced.natives_directory.hint=このオプションは、AppleM1またはその他の公式にサポートされていないプラットフォーム/アーキテクチャでゲームをプレイする場合にのみ使用されます。\nこのオプションを変更する意味がわからない場合は、このオプションを変更しないことをお勧めします。、またはゲームが起動しません。\nこのオプションを変更する必要がある場合は、ゲームを実行するために必要なすべてのネイティブライブラリをカスタムネイティブディレクトリの下に配置する必要があります。lwjgl.dll(liblwjgl.so)、openal.dll(libopenal.so)。HMCLは、不足しているネイティブライブラリを提供しません。
settings.advanced.natives_directory.hint=このオプションは、Appleシリコンまたはその他の公式にサポートされていないプラットフォーム/アーキテクチャでゲームをプレイする場合にのみ使用されます。\nこのオプションを変更する意味がわからない場合は、このオプションを変更しないことをお勧めします。、またはゲームが起動しません。\nこのオプションを変更する必要がある場合は、ゲームを実行するために必要なすべてのネイティブライブラリをカスタムネイティブディレクトリの下に配置する必要があります。lwjgl.dll(liblwjgl.so)、openal.dll(libopenal.so)。HMCLは、不足しているネイティブライブラリを提供しません。
settings.advanced.no_jvm_args=デフォルトのJVM引数なし
settings.advanced.precall_command=起動前コマンド
settings.advanced.precall_command.prompt=ゲーム開始前に実行されます
@@ -127,7 +127,7 @@ account.methods.microsoft.purchase=Купить Minecraft
account.methods.microsoft.snapshot=Вы используете неофициальную версию HMCL, загрузите официальную версию для входа в аккаунт Microsoft.
account.methods.microsoft.snapshot.website=Официальный сайт
account.methods.offline=Офлайн
account.methods.offline.name.special_characters=Рекомендуется использовать только английские буквы, цифры и символы подчёркивания.
account.methods.offline.name.special_characters=Использовать только буквы, цифры и подчеркивания (максимум 16 символов)
account.methods.offline.name.invalid.tip=Для имени пользователя рекомендуется использовать только английские буквы, цифры и подчеркивания, а его длина не должна превышать 16 символов.
account.methods.offline.name.invalid=Для имени пользователя рекомендуется использовать только английские буквы, цифры и подчеркивания, а его длина не должна превышать 16 символов.\n\
\n\
@@ -816,7 +816,7 @@ launcher.background.classic=Классическое
launcher.background.default=По умолчанию
launcher.background.default.tooltip=Или «background.png/.jpg/.gif/.webp» и изображения в папке «bg».
launcher.background.network=По ссылке
launcher.background.translucent=Полупрозрачный
launcher.background.paint=Сплошной цвет
launcher.cache_directory=Папка с кэшем
launcher.cache_directory.clean=Очистить кэш
launcher.cache_directory.choose=Выберите папку для кэша
@@ -908,6 +908,7 @@ modpack.type.manual.warning=Возможно, вам нужно напрямую
modpack.type.mcbbs=MCBBS
modpack.type.mcbbs.export=Может быть импортирован Hello Minecraft! Launcher
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=Может быть импортирован популярными сторонними лаунчеры
modpack.type.multimc=MultiMC
modpack.type.multimc.export=Может быть импортирован Hello Minecraft! Launcher и MultiMC
modpack.type.server=Сервер автообновления модпака
@@ -925,7 +926,10 @@ modpack.wizard.step.3.title=Выберите тип модпака, которы
modpack.wizard.step.initialization.exported_version=Сборка игры для экспорта
modpack.wizard.step.initialization.force_update=Принудительное обновление модпака до последней версии (вам понадобится служба хостинга файлов)
modpack.wizard.step.initialization.include_launcher=Включать лаунчер
modpack.wizard.step.initialization.modrinth.info=Лаунчер будет использовать удаленные ресурсы CurseForge/Modrinth вместо локальных файлов (включая моды, пакеты ресурсов и пакеты шейдеров) при создании модпака, чтобы уменьшить размер модпака, и помечать файлы с расширением «.disabled» как необязательные для установки.
modpack.wizard.step.initialization.no_create_remote_files=Не используйте удаленные файлы
modpack.wizard.step.initialization.save=Экспортировать в...
modpack.wizard.step.initialization.skip_curseforge_remote_files=Не используйте удаленные файлы CurseForge
modpack.wizard.step.initialization.warning=Перед созданием модпака убедитесь, что игра запускается нормально и Minecraft является релизной версией, а не снапшотом. Пусковая установка сохранит ваши настройки скачивания.\n\
\n\
Помните, что вам не разрешается добавлять моды и пакет ресурсов, в которых явно указано, что они не подлежат распространению или помещению в модпак.
@@ -1262,6 +1266,7 @@ settings.advanced.natives_directory=Папка с нативными библи
settings.advanced.natives_directory.choose=Выберите расоложение нужных нативных библиотек
settings.advanced.natives_directory.custom=Пользовательское
settings.advanced.natives_directory.default=По умолчанию
settings.advanced.natives_directory.default.version_id=<Название сборки>
settings.advanced.natives_directory.hint=Параметр предназначен только для пользователей Apple silicon или других официально не поддерживаемых платформ. Не изменяйте, если не знаете что делаете.\n\
\n\
Прежде чем продолжить, убедитесь что все библиотеки (например, lwjgl.dll, libopenal.so) находятся в нужной папке.\n\
@@ -1331,6 +1336,10 @@ settings.launcher.download_source=Источник скачивания
settings.launcher.download_source.auto=Автовыбор зеркала скачивания
settings.launcher.enable_game_list=Показывать список сборок на главной странице
settings.launcher.font=Шрифт
settings.launcher.font.anti_aliasing=Сглаживание
settings.launcher.font.anti_aliasing.auto=Автоматический
settings.launcher.font.anti_aliasing.gray=Оттенки серого
settings.launcher.font.anti_aliasing.lcd=Субпиксель
settings.launcher.general=Общие
settings.launcher.language=Язык (Применится после перезапуска)
settings.launcher.launcher_log.export=Экспорт логов лаунчера
@@ -1353,6 +1362,7 @@ settings.launcher.theme=Тема
settings.launcher.title_transparent=Прозрачная строка заголовка
settings.launcher.turn_off_animations=Отключить анимацию (Применится после перезапуска)
settings.launcher.version_list_source=Список версий
settings.launcher.background.settings.opacity=Непрозрачность
settings.memory=Оперативная память
settings.memory.allocate.auto=Минимум %1$.1f ГиБ / Выделено %2$.1f ГиБ
@@ -97,7 +97,7 @@ account.register=註冊
account.manage=帳戶清單
account.copy_uuid=複製該帳戶的 UUID
account.methods=登入方式
account.methods.authlib_injector=外置登入
account.methods.authlib_injector=外部登入
account.methods.microsoft=Microsoft 帳戶
account.methods.microsoft.birth=如何變更帳戶出生日期
account.methods.microsoft.deauthorize=移除應用存取權
@@ -434,7 +434,7 @@ game.crash.reason.mod_name=目前遊戲由於模組檔案名稱問題,無法
game.crash.reason.incomplete_forge_installation=目前遊戲由於 Forge 安裝不完整,無法繼續執行。\n請在「實例管理 - 自動安裝」中移除 Forge 並重新安裝。
game.crash.reason.file_already_exists=目前遊戲由於檔案「%1$s」已經存在,無法繼續執行。\n如果你認為這個檔案可以刪除,你可以在備份這個檔案後嘗試刪除它,並重新啟動遊戲。
game.crash.reason.file_changed=目前遊戲由於檔案校驗失敗,無法繼續執行。\n如果你手動修改了 Minecraft.jar 檔案,你需要回退修改,或者重新下載遊戲。
game.crash.reason.gl_operation_failure=目前遊戲由於你使用的某些模組/光影包/資源包/紋理包出現問題,無法繼續執行。\n請先嘗試禁用你所使用的模組/光影包/資源包/紋理包再試。
game.crash.reason.gl_operation_failure=目前遊戲由於你使用的某些模組/光影包/資源包/紋理包出現問題,無法繼續執行。\n請先嘗試停用你所使用的模組/光影包/資源包/紋理包再試。
game.crash.reason.graphics_driver=目前遊戲由於你的顯示卡驅動存在問題崩潰了。請嘗試升級你的顯示卡驅動到最新版本後再嘗試啟動遊戲。\n\
如果你的電腦同時存在獨立顯示卡與整合式顯示卡,你需要檢查遊戲是否使用整合式顯示卡啟動。如果是,請嘗試使用獨立顯示卡開啟 HMCL 與遊戲。如果仍有問題,你可能需要考慮換一個新顯示卡或新電腦。\n\
如果你確實需要使用整合式顯示卡,請檢查你電腦的 CPU 是否為 Intel(R) Core(TM) 3000 系列或更舊的處理器。如果是,對於 Minecraft 1.16.5 及更低版本,請你將遊戲所使用的 Java 版本降級至 1.8.0_51 及更低版本;否則你需要更換獨立顯示卡或新電腦。
@@ -530,7 +530,7 @@ install.installer.external_version=%s [由外部安裝的版本,無法解除
install.installing=安裝
install.modpack=安裝模組包
install.modpack.installation=安裝模組包
install.name.invalid=名稱中包含非 ASCII 字元(如 Emoji 表情或中文字元)。\n建議修改名稱。名稱建議僅包含英文字母、數字和底線,以防啟動遊戲時出現問題。是否繼續安裝?
install.name.invalid=名稱中包含特殊字元(如 Emoji 表情或中文字元)。\n建議修改名稱。名稱建議僅包含英文字母、數字和底線,以防啟動遊戲時出現問題。是否繼續安裝?
install.new_game=安裝新實例
install.new_game.already_exists=此實例已經存在,請重新命名
install.new_game.current_game_version=目前遊戲實例
@@ -541,9 +541,9 @@ install.success=安裝成功
java.add=添加 Java
java.add.failed=Java 無效或與目前平臺不相容
java.disable=禁用此 Java
java.disable.confirm=你確定要禁用此 Java 嗎?
java.disabled.management=管理已禁用的 Java
java.disable=停用此 Java
java.disable.confirm=你確定要停用此 Java 嗎?
java.disabled.management=管理已停用的 Java
java.disabled.management.remove=從清單中移除此 Java
java.disabled.management.restore=重新啟用此 Java
java.download=下載 Java
@@ -628,7 +628,7 @@ launcher.background.classic=經典
launcher.background.default=預設
launcher.background.default.tooltip=自動尋找啟動器同目錄下的「background.png/.jpg/.gif/.webp」及「bg」目錄內的圖片
launcher.background.network=網路
launcher.background.translucent=半透明
launcher.background.paint=純色
launcher.cache_directory=檔案下載快取目錄
launcher.cache_directory.clean=清理
launcher.cache_directory.choose=選取檔案下載快取目錄
@@ -720,6 +720,7 @@ modpack.type.manual.warning=該模組包由發佈者手動打包,其中可能
modpack.type.mcbbs=MCBBS 模組包
modpack.type.mcbbs.export=可以被 Hello Minecraft! Launcher 匯入
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=可以被主流第三方啟動器匯入
modpack.type.multimc=MultiMC
modpack.type.multimc.export=可以被 Hello Minecraft! Launcher 和 MultiMC 匯入
modpack.type.server=伺服器自動更新模組包
@@ -737,7 +738,10 @@ modpack.wizard.step.3.title=選取模組包匯出類型
modpack.wizard.step.initialization.exported_version=要匯出的遊戲實例
modpack.wizard.step.initialization.force_update=強制升級模組包至最新版本 (需要自建伺服器)
modpack.wizard.step.initialization.include_launcher=包含啟動器
modpack.wizard.step.initialization.modrinth.info=在模組包建立過程中,啟動器將匹配 CurseForge/Modrinth 遠端資源替代本機檔案(包括模組、資源包和光影包)以縮減模組包大小,並將副檔名為「.disabled」的檔案標註為「安裝時可選項」。
modpack.wizard.step.initialization.no_create_remote_files=不匹配遠端檔案
modpack.wizard.step.initialization.save=選取要匯出到的遊戲模組包位置
modpack.wizard.step.initialization.skip_curseforge_remote_files=不匹配 CurseForge 遠端資源
modpack.wizard.step.initialization.warning=在製作模組包前,請你確認你選取的實例可以正常啟動,\n並保證你的 Minecraft 是正式版而非快照,\n而且不應將不允許非官方途徑傳播的模組、資源 (紋理) 包等納入模組包。\n模組包會儲存你目前的下載來源設定。
modpack.wizard.step.initialization.server=點選此處查看有關伺服器自動更新模組包的製作教學
@@ -800,7 +804,7 @@ modrinth.category.optifine=OptiFine
modrinth.category.optimization=最佳化
modrinth.category.ornithe=Ornithe
modrinth.category.paper=Paper
modrinth.category.path-tracing=路径追踪
modrinth.category.path-tracing=路徑追蹤
modrinth.category.pbr=PBR
modrinth.category.potato=極低
modrinth.category.purpur=Purpur
@@ -887,7 +891,7 @@ datapack.choose_datapack=選取要匯入的資料包壓縮檔
datapack.extension=資料包
datapack.title=世界 [%s] - 資料包
web.failed=加載頁面失敗
web.failed=載入頁面失敗
web.open_in_browser=是否要在瀏覽器中開啟此連結:\n%s
web.view_in_browser=在瀏覽器中查看完整日誌
@@ -895,10 +899,10 @@ world=世界
world.add=新增世界
world.backup=世界備份
world.backup.create.new_one=建立新備份
world.backup.create.failed=創建備份失敗。\n%s
world.backup.create.failed=建立備份失敗。\n%s
world.backup.create.locked=該世界正在使用中,請關閉遊戲後重試。
world.backup.create.success=成功創建新備份:%s
world.backup.delete=删除此備份
world.backup.create.success=成功建立新備份:%s
world.backup.delete=刪除此備份
world.backup.processing=正在備份中……
world.chunkbase=世界地圖
world.chunkbase.end_city=終界城地圖
@@ -989,7 +993,7 @@ schematics.create_directory.prompt=請輸入新目錄名稱
schematics.create_directory.failed=建立目錄失敗
schematics.create_directory.failed.already_exists=目錄已存在
schematics.create_directory.failed.empty_name=名稱不能為空
schematics.create_directory.failed.invalid_name=名稱中包含非法字元
schematics.create_directory.failed.invalid_name=名稱中包含無效字元
schematics.info.description=描述
schematics.info.enclosing_size=框大小
schematics.info.name=名稱
@@ -1037,7 +1041,7 @@ settings.advanced.custom_commands.hint=自訂指令被呼叫時將包含如下
\ · $INST_FABRIC: 若安裝了 Fabric,將會存在本環境變數;\n\
\ · $INST_QUILT: 若安裝了 Quilt,將會存在本環境變數。
settings.advanced.dont_check_game_completeness=不檢查遊戲完整性
settings.advanced.dont_check_jvm_validity=不檢查 JVM 與遊戲的相容性
settings.advanced.dont_check_jvm_validity=不檢查 Java 虛擬機與遊戲的相容性
settings.advanced.dont_patch_natives=不嘗試自動取代本機庫
settings.advanced.environment_variables=環境變數
settings.advanced.game_dir.default=預設 (".minecraft/")
@@ -1047,7 +1051,7 @@ settings.advanced.java_permanent_generation_space.prompt=單位 MiB
settings.advanced.jvm=Java 虛擬機設定
settings.advanced.jvm_args=Java 虛擬機參數
settings.advanced.jvm_args.prompt=\ · 若在「Java 虛擬機參數」中輸入的參數與預設參數相同,則不會新增;\n\
\ · 若在「Java 虛擬機參數」輸入任何 GC 參數,預設參數的 G1 參數會被禁用;\n\
\ · 若在「Java 虛擬機參數」輸入任何 GC 參數,預設參數的 G1 參數會被停用;\n\
\ · 啟用下方「不新增預設的 Java 虛擬機參數」可在啟動遊戲時不新增預設參數。
settings.advanced.launcher_visibility.close=遊戲啟動後結束啟動器
settings.advanced.launcher_visibility.hide=遊戲啟動後隱藏啟動器
@@ -1060,8 +1064,9 @@ settings.advanced.natives_directory=本機庫路徑 (LWJGL)
settings.advanced.natives_directory.choose=選取本機庫路徑
settings.advanced.natives_directory.custom=自訂 (由你提供遊戲需要的本機庫)
settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本機庫)
settings.advanced.natives_directory.hint=本選項提供給 Apple M1 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
settings.advanced.no_jvm_args=不新增預設的 JVM 參數
settings.advanced.natives_directory.default.version_id=<實例名稱>
settings.advanced.natives_directory.hint=本選項提供給 Apple Silicon 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
settings.advanced.precall_command=遊戲啟動前執行指令
settings.advanced.precall_command.prompt=將在遊戲啟動前呼叫使用
settings.advanced.process_priority=處理程式優先度
@@ -1124,9 +1129,9 @@ settings.launcher.download_source=下載來源
settings.launcher.download_source.auto=自動選取下載來源
settings.launcher.enable_game_list=在首頁內顯示遊戲清單
settings.launcher.font=字體
settings.launcher.font.anti_aliasing=抗鋸齒 (重啟後生效)
settings.launcher.font.anti_aliasing=反鋸齒 (重啟後生效)
settings.launcher.font.anti_aliasing.auto=自動
settings.launcher.font.anti_aliasing.gray=灰度
settings.launcher.font.anti_aliasing.gray=灰階
settings.launcher.font.anti_aliasing.lcd=子像素
settings.launcher.general=一般
settings.launcher.language=語言 (重啟後生效)
@@ -1150,6 +1155,7 @@ settings.launcher.theme=主題
settings.launcher.title_transparent=標題欄透明
settings.launcher.turn_off_animations=關閉動畫 (重啟後生效)
settings.launcher.version_list_source=版本清單來源
settings.launcher.background.settings.opacity=不透明度
settings.memory=遊戲記憶體
settings.memory.allocate.auto=最低分配 %1$.1f GiB / 實際分配 %2$.1f GiB
@@ -540,7 +540,7 @@ install.installer.external_version=%s (由外部安装的版本,无法卸载
install.installing=安装
install.modpack=安装整合包
install.modpack.installation=安装整合包
install.name.invalid=名称中包含非 ASCII 字符(如 Emoji 表情或中文字符)。\n建议修改名称。名称建议仅包含英文字母、数字和下划线,以防启动游戏时出现问题。是否继续安装?
install.name.invalid=名称中包含特殊字符(如 Emoji 表情或中文字符)。\n建议修改名称。名称建议仅包含英文字母、数字和下划线,以防启动游戏时出现问题。是否继续安装?
install.new_game=安装新游戏
install.new_game.already_exists=此版本已经存在,请换一个名字
install.new_game.current_game_version=当前游戏版本
@@ -638,7 +638,7 @@ launcher.background.classic=经典
launcher.background.default=默认
launcher.background.default.tooltip=自动检索启动器同文件夹下的“background.png/.jpg/.gif/.webp”及“bg”文件夹内的图片
launcher.background.network=网络
launcher.background.translucent=半透明
launcher.background.paint=纯色
launcher.cache_directory=文件下载缓存文件夹
launcher.cache_directory.clean=清理缓存
launcher.cache_directory.choose=选择文件下载缓存文件夹
@@ -730,6 +730,7 @@ modpack.type.manual.warning=该整合包由发布者手动打包,其中可能
modpack.type.mcbbs=MCBBS 整合包 (推荐)
modpack.type.mcbbs.export=可以被 Hello Minecraft! Launcher 导入
modpack.type.modrinth=Modrinth
modpack.type.modrinth.export=可以被主流第三方启动器导入
modpack.type.multimc=MultiMC
modpack.type.multimc.export=可以被 Hello Minecraft! Launcher 和 MultiMC 导入
modpack.type.server=服务器自动更新整合包
@@ -747,7 +748,10 @@ modpack.wizard.step.3.title=选择整合包导出类型
modpack.wizard.step.initialization.exported_version=要导出的游戏版本
modpack.wizard.step.initialization.force_update=强制升级整合包至最新版本 (需要自建服务器)
modpack.wizard.step.initialization.include_launcher=包含启动器
modpack.wizard.step.initialization.modrinth.info=在整合包创建过程中,启动器将匹配 CurseForge/Modrinth 远程资源替代本地文件(包括模组、资源包和光影包)以缩减整合包体积,并将扩展名为“.disabled”的文件标注为“安装时可选项”。
modpack.wizard.step.initialization.no_create_remote_files=不匹配远程文件
modpack.wizard.step.initialization.save=选择要导出到的游戏整合包位置
modpack.wizard.step.initialization.skip_curseforge_remote_files=不匹配 CurseForge 远程资源
modpack.wizard.step.initialization.warning=在制作整合包前,请你确认你选择的版本可以正常启动,\n并保证你的 Minecraft 是正式版而非快照,\n而且不应当将不允许非官方途径传播的模组、资源 (纹理) 包等纳入整合包。\n整合包会保存你目前的下载源设置。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
modpack.wizard.step.initialization.server=点击此处查看有关服务器自动更新整合包的制作教程
@@ -1047,7 +1051,7 @@ settings.advanced.custom_commands.hint=自定义命令被调用时将包含如
\ · $INST_FABRIC: 若安装了 Fabric,将会存在本环境变量;\n\
\ · $INST_QUILT: 若安装了 Quilt,将会存在本环境变量。
settings.advanced.dont_check_game_completeness=不检查游戏完整性
settings.advanced.dont_check_jvm_validity=不检查 JVM 与游戏的兼容性
settings.advanced.dont_check_jvm_validity=不检查 Java 虚拟机与游戏的兼容性
settings.advanced.dont_patch_natives=不尝试自动替换本地库
settings.advanced.environment_variables=环境变量
settings.advanced.game_dir.default=默认 (".minecraft/")
@@ -1070,8 +1074,9 @@ settings.advanced.natives_directory=本地库路径 (LWJGL)
settings.advanced.natives_directory.choose=选择本地库路径
settings.advanced.natives_directory.custom=自定义 (由你提供游戏需要的本地库)
settings.advanced.natives_directory.default=默认 (由启动器提供游戏本地库)
settings.advanced.natives_directory.hint=本选项提供给 Apple M1 等未受游戏官方支持的平台来自定义游戏本地库。如果你不知道本选项的含义,请不要修改本选项,否则会导致游戏无法启动!\n\n如果你要修改本选项,你需要保证自定义文件夹下有游戏所需的本地库文件,如 lwjgl.dll (liblwjgl.so)、openal.dll (libopenal.so) 等文件。启动器不会帮你补全缺少的本地库文件!\n\n注意:指定的本地库文件路径建议只包含英文大小写字母、数字和下划线,否则可能会导致启动游戏失败。
settings.advanced.no_jvm_args=不添加默认的 JVM 参数
settings.advanced.natives_directory.default.version_id=<版本名称>
settings.advanced.natives_directory.hint=本选项提供给 Apple Silicon 等未受游戏官方支持的平台来自定义游戏本地库。如果你不知道本选项的含义,请不要修改本选项,否则会导致游戏无法启动!\n\n如果你要修改本选项,你需要保证自定义文件夹下有游戏所需的本地库文件,如 lwjgl.dll (liblwjgl.so)、openal.dll (libopenal.so) 等文件。启动器不会帮你补全缺少的本地库文件!\n\n注意:指定的本地库文件路径建议只包含英文大小写字母、数字和下划线,否则可能会导致启动游戏失败。
settings.advanced.no_jvm_args=不添加默认的 Java 虚拟机参数
settings.advanced.precall_command=游戏启动前执行命令
settings.advanced.precall_command.prompt=将在游戏启动前调用
settings.advanced.process_priority=进程优先级
@@ -1160,6 +1165,7 @@ settings.launcher.theme=主题
settings.launcher.title_transparent=标题栏透明
settings.launcher.turn_off_animations=关闭动画 (重启后生效)
settings.launcher.version_list_source=版本列表源
settings.launcher.background.settings.opacity=不透明度
settings.memory=游戏内存
settings.memory.allocate.auto=最低分配 %1$.1f GiB / 实际分配 %2$.1f GiB
@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class ImageUtilsTest {
private static byte[] readHeaderBuffer(String fileName) {
try (var input = Files.newInputStream(Path.of("src/test/resources/image/" + fileName))) {
return input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
} catch (IOException e) {
throw new AssertionError(e);
}
}
@Test
public void testIsApng() {
assertTrue(ImageUtils.isApng(readHeaderBuffer("16x16.apng")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16.png")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-lossless.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-lossy.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
}
@Test
public void testIsWebP() {
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.apng")));
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.png")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-lossless.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-lossy.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
}
}
@@ -0,0 +1,28 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.image;
import javafx.application.Application;
import javafx.stage.Stage;
public class ImageViewTest extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
}
}
@@ -0,0 +1,44 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url.data;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class DataUriTest {
private static String readString(String uri) throws IOException {
return new DataUri(URI.create(uri)).readString();
}
private static byte[] readBytes(String uri) throws IOException {
return new DataUri(URI.create(uri)).readBytes();
}
@Test
public void testReadString() throws IOException {
assertEquals("Hello, World!", readString("data:,Hello%2C%20World%21"));
assertEquals("Hello, World!", readString("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="));
assertEquals("<h1>Hello, World!</h1>", readString("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E"));
assertEquals("<script>alert('hi');</script>", readString("data:text/html,%3Cscript%3Ealert%28%27hi%27%29%3B%3C%2Fscript%3E"));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more