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 ff054a8c15..a176e9b15f 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 @@ -40,6 +40,7 @@ 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.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -50,13 +51,16 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * @author Glavo - */ +/// Provides image-format detection and loaders for image formats not handled +/// directly by JavaFX. +/// +/// @author Glavo +@NotNullByDefault public final class ImageUtils { // ImageLoaders @@ -81,38 +85,37 @@ public final class ImageUtils { LoaderParameters parameters = new LoaderParameters(); parameters.autoStartAnimations = false; - SVGImage image; + Supplier supplier = () -> { + SVGImage image = SVGLoader.load(content, parameters); + 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); + } + }; + + @Nullable Image image; if (Platform.isFxApplicationThread()) { - image = SVGLoader.load(content, parameters); + image = supplier.get(); } 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(); + image = CompletableFuture.supplyAsync(supplier, 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); - } + return image; }; public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> { @@ -198,6 +201,7 @@ public final class ImageUtils { // ------ + /// Maximum number of leading bytes inspected when selecting an image loader. public static final int HEADER_BUFFER_SIZE = 1024; private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'}; @@ -209,12 +213,272 @@ public final class ImageUtils { && 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); + int offset = hasUtf8ByteOrderMark(headerBuffer) ? 3 : 0; + + while (true) { + offset = skipXmlWhitespace(headerBuffer, offset); + + if (isSvgElementStart(headerBuffer, offset)) + return true; + + if (matchesAscii(headerBuffer, offset, XML_COMMENT_START)) { + offset = indexAfterAscii( + headerBuffer, + offset + XML_COMMENT_START.length(), + XML_COMMENT_END + ); + } else if (matchesAscii(headerBuffer, offset, XML_PROCESSING_INSTRUCTION_START)) { + offset = indexAfterAscii( + headerBuffer, + offset + XML_PROCESSING_INSTRUCTION_START.length(), + XML_PROCESSING_INSTRUCTION_END + ); + } else if (isDoctypeStart(headerBuffer, offset)) { + offset = skipDoctype(headerBuffer, offset); + } else { + return false; + } + + if (offset < 0) + return false; + } + } + + /// Returns whether the byte array starts with a UTF-8 byte-order mark. + /// + /// @param data bytes to inspect + /// @return whether `data` begins with `EF BB BF` + private static boolean hasUtf8ByteOrderMark(byte[] data) { + return svgScanLimit(data) >= 3 + && data[0] == (byte) 0xef + && data[1] == (byte) 0xbb + && data[2] == (byte) 0xbf; + } + + /// Returns the bounded length available to SVG detection. + /// + /// @param data source bytes + /// @return the lesser of the array length and [#HEADER_BUFFER_SIZE] + private static int svgScanLimit(byte[] data) { + return Math.min(data.length, HEADER_BUFFER_SIZE); + } + + /// Skips XML whitespace from an array offset. + /// + /// @param data source bytes + /// @param start inclusive offset at which to begin + /// @return the first offset that does not contain XML whitespace + private static int skipXmlWhitespace(byte[] data, int start) { + int limit = svgScanLimit(data); + while (start < limit && isXmlWhitespace(data[start])) + start++; + return start; + } + + /// Returns whether an SVG document-element qualified name begins at an array offset. + /// + /// @param data source bytes + /// @param start offset of a possible element opening delimiter + /// @return whether the unprefixed or prefixed element has the local name `svg` + private static boolean isSvgElementStart(byte[] data, int start) { + int limit = svgScanLimit(data); + if (start < 0 || start >= limit || data[start] != '<') + return false; + + int nameStart = start + 1; + int localNameStart = nameStart; + boolean hasPrefix = false; + + for (int index = nameStart; index < limit; index++) { + byte value = data[index]; + if (value == '>' || isXmlWhitespace(value) + || value == '/' && index + 1 < limit && data[index + 1] == '>') { + return index - localNameStart == SVG_ELEMENT_NAME.length() + && matchesAscii(data, localNameStart, SVG_ELEMENT_NAME); + } + + if (value == ':') { + if (hasPrefix || index == nameStart) + return false; + hasPrefix = true; + localNameStart = index + 1; + continue; + } + + boolean nameStartCharacter = index == nameStart || index == localNameStart; + if (nameStartCharacter + ? !isAsciiXmlNameStart(value) + : !isAsciiXmlNamePart(value)) { + return false; + } + } + + return false; + } + + /// Returns whether a byte can begin an ASCII XML name component. + /// + /// @param value byte to inspect + /// @return whether the byte is an ASCII letter or underscore + private static boolean isAsciiXmlNameStart(byte value) { + return value >= 'A' && value <= 'Z' + || value >= 'a' && value <= 'z' + || value == '_'; + } + + /// Returns whether a byte can continue an ASCII XML name component. + /// + /// @param value byte to inspect + /// @return whether the byte is accepted after the first name character + private static boolean isAsciiXmlNamePart(byte value) { + return isAsciiXmlNameStart(value) + || value >= '0' && value <= '9' + || value == '-' + || value == '.'; + } + + /// Returns whether an XML document type declaration begins at an offset. + /// + /// @param data source bytes + /// @param start offset of a possible declaration + /// @return whether the case-sensitive keyword is followed by XML whitespace + private static boolean isDoctypeStart(byte[] data, int start) { + int keywordEnd = start + XML_DOCTYPE_START.length(); + return matchesAscii(data, start, XML_DOCTYPE_START) + && keywordEnd < svgScanLimit(data) + && isXmlWhitespace(data[keywordEnd]); + } + + /// Skips an XML document type declaration, including its internal subset. + /// + /// Quoted values, comments, and processing instructions are ignored while + /// locating the declaration-closing `>`. + /// + /// @param data source bytes + /// @param start offset of the ` 0) { + internalSubsetDepth--; + } else if (value == '>' && internalSubsetDepth == 0) { + return index + 1; + } + } + + return -1; + } + + /// Finds an ASCII sequence in a byte array. + /// + /// @param data source bytes + /// @param start inclusive offset at which to begin searching + /// @param asciiValue ASCII sequence to find + /// @return the offset after the first match, or `-1` if no match exists + private static int indexAfterAscii(byte[] data, int start, String asciiValue) { + int lastStart = svgScanLimit(data) - asciiValue.length(); + for (int index = start; index <= lastStart; index++) { + if (matchesAscii(data, index, asciiValue)) + return index + asciiValue.length(); + } + return -1; + } + + /// Compares an ASCII string with bytes at an array offset. + /// + /// @param data source bytes + /// @param start offset at which to compare + /// @param asciiValue ASCII sequence to compare + /// @return whether the complete sequence matches + private static boolean matchesAscii(byte[] data, int start, String asciiValue) { + if (start < 0 || start > svgScanLimit(data) - asciiValue.length()) + return false; + + for (int index = 0; index < asciiValue.length(); index++) { + if (Byte.toUnsignedInt(data[start + index]) != asciiValue.charAt(index)) + return false; + } + return true; + } + + /// Returns whether a byte is XML whitespace. + /// + /// @param value byte to inspect + /// @return whether the byte is space, tab, carriage return, or line feed + private static boolean isXmlWhitespace(byte value) { + return value == ' ' || value == '\t' || value == '\r' || value == '\n'; } private static final byte[] PNG_HEADER = { @@ -249,7 +513,7 @@ public final class ImageUtils { ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8); - PngChunkHeader header; + @Nullable 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. @@ -436,7 +700,7 @@ public final class ImageUtils { } } - PngAnimationControl animationControl = sequence.getAnimationControl(); + @Nullable PngAnimationControl animationControl = sequence.getAnimationControl(); int cycleCount; if (animationControl != null) { cycleCount = animationControl.numPlays(); @@ -465,6 +729,7 @@ public final class ImageUtils { return argb; } + /// Prevents instantiation. private ImageUtils() { } } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java index 53c2ae0f9f..1a40df052a 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/image/ImageUtilsTest.java @@ -17,17 +17,26 @@ */ package org.jackhuang.hmcl.ui.image; +import org.jetbrains.annotations.NotNullByDefault; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.nio.charset.StandardCharsets; 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +/// Verifies image-format detection from bounded input prefixes. +@NotNullByDefault public final class ImageUtilsTest { + /// Reads the image-loader detection prefix of a test resource. + /// + /// @param fileName resource file name + /// @return up to [ImageUtils#HEADER_BUFFER_SIZE] leading bytes 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); @@ -37,6 +46,15 @@ public final class ImageUtilsTest { } } + /// Encodes an XML fixture without introducing non-ASCII bytes. + /// + /// @param value fixture text + /// @return US-ASCII bytes + private static byte[] asciiBytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + /// Verifies APNG detection distinguishes animated and static PNG files. @Test public void testIsApng() { assertTrue(ImageUtils.isApng(readHeaderBuffer("16x16.apng"))); @@ -47,6 +65,7 @@ public final class ImageUtilsTest { assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp"))); } + /// Verifies WebP detection recognizes all supported WebP variants. @Test public void testIsWebP() { assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.apng"))); @@ -57,4 +76,66 @@ public final class ImageUtilsTest { assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp"))); } + /// Verifies SVG detection accepts XML prologs and an SVG document element. + @Test + public void testIsSVG() { + assertTrue(ImageUtils.isSVG(asciiBytes(""))); + assertTrue(ImageUtils.isSVG(asciiBytes(" \t\r\n"))); + assertTrue(ImageUtils.isSVG( + "\ufeff\n" + .getBytes(StandardCharsets.UTF_8) + )); + assertTrue(ImageUtils.isSVG(asciiBytes( + "" + + "" + + "" + + "" + ))); + assertTrue(ImageUtils.isSVG(asciiBytes( + " '>" + + "" + + "" + + "]>" + ))); + assertTrue(ImageUtils.isSVG(asciiBytes( + "" + ))); + assertTrue(ImageUtils.isSVG(asciiBytes( + "" + ))); + assertTrue(ImageUtils.isSVG(asciiBytes( + "" + ))); + } + + /// Verifies SVG detection rejects incomplete, embedded, and non-SVG roots. + @Test + public void testIsNotSVG() { + assertFalse(ImageUtils.isSVG(new byte[0])); + assertFalse(ImageUtils.isSVG(asciiBytes(""))); + assertFalse(ImageUtils.isSVG(asciiBytes(""))); + assertFalse(ImageUtils.isSVG(asciiBytes(""))); + assertFalse(ImageUtils.isSVG(asciiBytes(""))); + assertFalse(ImageUtils.isSVG(asciiBytes("plain text "))); + assertFalse(ImageUtils.isSVG(asciiBytes(""))); + assertFalse(ImageUtils.isSVG(asciiBytes( + "" + ))); + assertFalse(ImageUtils.isSVG(asciiBytes( + " " + ))); + assertFalse(ImageUtils.isSVG(asciiBytes( + "" + ))); + } + + /// Verifies sniffing routes a prefixed SVG document to the SVG loader. + @Test + public void testGuessLoaderDetectsPrefixedSVG() { + assertSame(ImageUtils.SVG, ImageUtils.guessLoader(asciiBytes( + "" + ))); + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c639722dd7..2c5876b9cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ hello-nbt = "0.3.0" nanohttpd = "2.3.1" jsoup = "1.21.2" chardet = "2.5.0" -fxsvgimage = "1.3" +fxsvgimage = "1.9.1" jna = "5.18.1" pci-ids = "0.4.0" java-info = "1.0"