diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 156969060b..b0860c1234 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(project(":HMCLBoot")) implementation("libs:JFoenix") implementation(libs.twelvemonkeys.imageio.webp) + implementation(libs.fxsvgimage) implementation(libs.java.info) implementation(libs.monet.fx) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index 2426a1ced2..ba0e03a91f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -310,7 +310,7 @@ public Image getVersionIconImage(String id) { Optional iconFile = getVersionIconFile(id); if (iconFile.isPresent()) { try { - return FXUtils.loadImage(iconFile.get()); + return FXUtils.loadImage(iconFile.get(), 64, 64, true, true); } catch (Exception e) { LOG.warning("Failed to load version icon of " + id, e); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index d233b8f584..beaa9351a5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -235,8 +235,8 @@ private FXUtils() { public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace"; - public static final List IMAGE_EXTENSIONS = Lang.immutableListOf( - "png", "jpg", "jpeg", "bmp", "gif", "webp", "apng" + public static final List IMAGE_EXTENSIONS = List.of( + "png", "jpg", "jpeg", "bmp", "gif", "webp", "svg", "apng" ); private static final Map builtinImageCache = new ConcurrentHashMap<>(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java index 261ab519cb..038ad71212 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java @@ -19,9 +19,17 @@ import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi; import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.scene.SnapshotParameters; import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; +import org.girod.javafx.svgimage.LoaderParameters; +import org.girod.javafx.svgimage.SVGImage; +import org.girod.javafx.svgimage.SVGLoader; +import org.girod.javafx.svgimage.ScaleQuality; +import org.jackhuang.hmcl.task.Schedulers; 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; @@ -39,7 +47,9 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -73,6 +83,46 @@ public final class ImageUtils { return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth); }; + public static final ImageLoader SVG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> { + String content = new String(input.readAllBytes(), StandardCharsets.UTF_8); + + LoaderParameters parameters = new LoaderParameters(); + parameters.autoStartAnimations = false; + + SVGImage image; + + if (Platform.isFxApplicationThread()) { + image = SVGLoader.load(content, parameters); + } else { + // TODO: Currently, SVGLoader.load(...) requires the javafx.swing module if it operates on a non-JavaFX thread. + image = CompletableFuture.supplyAsync( + () -> SVGLoader.load(content, parameters), + Schedulers.javafx() + ).get(); + } + + if (image == null) + throw new IOException("Failed to load SVG image"); + + var snapshotParameters = new SnapshotParameters(); + snapshotParameters.setFill(Color.TRANSPARENT); + + if (requestedWidth <= 0. || requestedHeight <= 0.) { + return image.toImage(snapshotParameters); + } + + double scaleX = requestedWidth / image.getScaledWidth(); + double scaleY = requestedHeight / image.getScaledHeight(); + + if (preserveRatio || scaleX == scaleY) { + double scale = Math.min(scaleX, scaleY); + return image.scale(scale).toImage(snapshotParameters); + } else { + // FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS + return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY); + } + }; + 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); @@ -136,11 +186,13 @@ public final class ImageUtils { public static final Map EXT_TO_LOADER = Map.of( "webp", WEBP, + "svg", SVG, "apng", APNG ); public static final Map CONTENT_TYPE_TO_LOADER = Map.of( "image/webp", WEBP, + "image/svg+xml", SVG, "image/apng", APNG ); @@ -165,6 +217,14 @@ public static boolean isWebP(byte[] headerBuffer) { && Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4); } + private static final byte[] SVG_HEADER = " SVG_HEADER.length + && Arrays.equals(headerBuffer, 0, SVG_HEADER.length, SVG_HEADER, 0, SVG_HEADER.length); + } + private static final byte[] PNG_HEADER = { (byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47, (byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a, @@ -232,6 +292,8 @@ public static boolean isApng(byte[] headerBuffer) { return WEBP; if (isApng(headerBuffer)) return APNG; + if (isSVG(headerBuffer)) + return SVG; return null; } diff --git a/HMCL/src/main/resources/assets/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index c121c5360f..a601603ba0 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -69,6 +69,11 @@ "subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.", "externalLink" : "https://github.com/aellerton/japng" }, + { + "title" : "fxsvgimage", + "subtitle" : "Copyright (c) 2021, 2022, 2025 Hervé Girod.\nLicensed under the BSD 3-clause License.", + "externalLink" : "https://github.com/hervegirod/fxsvgimage" + }, { "title": "Terracotta", "subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2897211f61..dbb19a9c7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ nanohttpd = "2.3.1" jsoup = "1.21.2" chardet = "2.5.0" twelvemonkeys = "3.13.0" +fxsvgimage = "1.3" jna = "5.18.1" pci-ids = "0.4.0" java-info = "1.0" @@ -43,6 +44,7 @@ nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } chardet = { module = "org.glavo:chardet", version.ref = "chardet" } twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" } +fxsvgimage = { module = "com.github.hervegirod:fxsvgimage", version.ref = "fxsvgimage" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } pci-ids = { module = "org.glavo:pci-ids", version.ref = "pci-ids" }