From 0a4550af2633c9dc69dbd34d130c92d313aa10cd Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 8 Jun 2026 11:33:50 +0300 Subject: [PATCH] feat(commons): add image format sniffer + compression types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 part A of desktop image compression. Pure data types — no external dependencies yet beyond Thumbnailator already on classpath. - CompressionQuality enum (Low / Medium / Desktop High) with JPEG quality values tuned for 2026 displays (0.65 / 0.75 / 0.90) rather than the obsolete 2014-era Android values. - ImageFormat sealed class covering the 9 formats the orchestrator must distinguish (JPEG/PNG/BMP/TIFF re-encoded, animated GIF and animated WebP byte-identical pass-through, SVG pass-through, AVIF and HEIC refused for lack of a pure-Java decoder). - ImageFormatSniffer with magic-byte detection + RFC 9649 VP8X animation-flag check + GIF NETSCAPE2.0 application-extension scan + ISO BMFF ftyp brand match for AVIF / HEIC variants. - CompressionException sealed hierarchy (UnsupportedFormat / InputTooLarge / EncodeFailed) with initCause for chain preservation through logging. 23 sniffer unit tests cover each format including JPEG-via-file, empty input, missing file, and both WebP animation paths. --- .../service/upload/CompressionException.kt | 60 +++++ .../service/upload/CompressionQuality.kt | 55 +++++ .../commons/service/upload/ImageFormat.kt | 88 ++++++++ .../service/upload/ImageFormatSniffer.kt | 172 +++++++++++++++ .../service/upload/ImageFormatSnifferTest.kt | 206 ++++++++++++++++++ 5 files changed, 581 insertions(+) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt new file mode 100644 index 0000000000..066463a75f --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +/** + * Typed failures from the image-compression pipeline. The fail-loud + * dialog renders [message] directly; subclass identity drives the + * "Send Original" decision (e.g., InputTooLarge → user can still + * bypass; UnsupportedFormat → bypass uploads raw bytes). + * + * Each subclass calls `initCause` (via the secondary constructor) so + * the cause chain survives logging — Kotlin's primary constructor + * does not auto-wire `cause` to `Throwable.cause` when the parent + * constructor receives both message and cause. + */ +sealed class CompressionException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + init { + if (cause != null) initCause(cause) + } + + /** Source format has no usable decoder in v1 (AVIF, HEIC). */ + class UnsupportedFormat( + val format: String, + ) : CompressionException("Format not supported: $format") + + /** + * Source pixel count exceeds [ImageReencoder.MAX_INPUT_PIXELS] + * (50 megapixels by default). Guards against memory-bomb inputs. + */ + class InputTooLarge( + val pixels: Long, + val limit: Long, + ) : CompressionException("Image has $pixels pixels — exceeds the $limit limit") + + /** Decoder or encoder threw — wrap the cause. */ + class EncodeFailed( + cause: Throwable, + ) : CompressionException("Image encode failed: ${cause.message ?: cause::class.simpleName}", cause) +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt new file mode 100644 index 0000000000..1c79f185b3 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +/** + * Image compression quality presets. JPEG quality values are tuned for + * 2026 display densities — the 2014-era Android values (q=0.40/0.50/0.80) + * produce visible blocking on modern displays. + * + * @property displayName Human-readable label for settings UI and the + * per-post override chip. + * @property maxDim Maximum width OR height in pixels for the encoded + * output. The image keeps its aspect ratio; the longer edge clamps to + * this value. ImageReencoder enforces "never upscale": if both source + * dimensions are already within this box, the source dims are kept + * and only the JPEG re-encode runs. + * @property jpegQuality JPEG quality factor in [0.0, 1.0] passed to + * `javax.imageio.ImageWriteParam.setCompressionQuality(...)`. + */ +enum class CompressionQuality( + val displayName: String, + val maxDim: Int, + val jpegQuality: Float, +) { + LOW("Low", 640, 0.65f), + MEDIUM("Medium", 640, 0.75f), + DESKTOP_HIGH("Desktop High", 1920, 0.90f), + ; + + companion object { + /** Default for first-install. */ + val DEFAULT: CompressionQuality = DESKTOP_HIGH + + /** Round-trip from a stored preference value. Falls back to [DEFAULT]. */ + fun fromName(name: String?): CompressionQuality = entries.firstOrNull { it.name == name } ?: DEFAULT + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt new file mode 100644 index 0000000000..ab5f2a46ec --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +/** + * Container image format produced by [ImageFormatSniffer]. The + * sniffer's job is to decide whether a payload re-encodes through the + * pipeline, passes through byte-identical, or refuses upload entirely. + */ +sealed class ImageFormat { + /** MIME type for the upload payload. */ + abstract val mimeType: String + + object Jpeg : ImageFormat() { + override val mimeType = "image/jpeg" + } + + object Png : ImageFormat() { + override val mimeType = "image/png" + } + + object Bmp : ImageFormat() { + override val mimeType = "image/bmp" + } + + object Tiff : ImageFormat() { + override val mimeType = "image/tiff" + } + + /** + * Animated GIFs must pass through byte-identical — ImageIO would + * only encode the first frame. + */ + data class Gif( + val animated: Boolean, + ) : ImageFormat() { + override val mimeType = "image/gif" + } + + /** + * Animated WebPs must pass through byte-identical. Static WebPs + * decode via stock ImageIO (limited) — for v1 they pass through + * unchanged since there is no pure-Java WebP encoder. + */ + data class WebP( + val animated: Boolean, + ) : ImageFormat() { + override val mimeType = "image/webp" + } + + /** Vector format — pass through unchanged. */ + object Svg : ImageFormat() { + override val mimeType = "image/svg+xml" + } + + /** Detected but refused — no pure-Java decoder exists in 2026. */ + object Avif : ImageFormat() { + override val mimeType = "image/avif" + } + + /** Detected but refused — no pure-Java decoder exists in 2026. */ + object Heic : ImageFormat() { + override val mimeType = "image/heic" + } + + /** Magic bytes did not match any known format. */ + data class Unknown( + override val mimeType: String, + ) : ImageFormat() +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt new file mode 100644 index 0000000000..33b186d612 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import java.io.File + +/** + * Magic-byte sniffer for image upload payloads. Detection runs on a + * small header prefix — full file decode is left to ImageReencoder. + * + * For containers that can be either still or animated (GIF, WebP) the + * sniffer scans deeper into the bytes to set the `animated` flag, so + * the orchestrator can short-circuit to byte-identical pass-through + * for animations (ImageIO would otherwise encode only frame 0). + */ +object ImageFormatSniffer { + /** Bytes from the start of the file used to sniff. */ + private const val PREFIX_BYTES = 8192 + + fun sniff(file: File): ImageFormat { + if (!file.exists() || file.length() == 0L) return ImageFormat.Unknown("application/octet-stream") + val prefix = + file.inputStream().use { input -> + val buf = ByteArray(minOf(PREFIX_BYTES.toLong(), file.length()).toInt()) + var read = 0 + while (read < buf.size) { + val n = input.read(buf, read, buf.size - read) + if (n < 0) break + read += n + } + if (read == buf.size) buf else buf.copyOf(read) + } + return sniff(prefix) + } + + fun sniff(bytes: ByteArray): ImageFormat { + if (bytes.size < 4) return ImageFormat.Unknown("application/octet-stream") + + // JPEG: FF D8 FF + if (bytes.startsWith(0xFF, 0xD8, 0xFF)) return ImageFormat.Jpeg + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (bytes.startsWith(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) return ImageFormat.Png + + // GIF: "GIF87a" or "GIF89a". Animation detected via NETSCAPE2.0 + // application extension (animated GIFs almost always carry it). + if (bytes.startsWith('G', 'I', 'F', '8') && bytes.size >= 6 && + (bytes[4] == '7'.code.toByte() || bytes[4] == '9'.code.toByte()) && + bytes[5] == 'a'.code.toByte() + ) { + return ImageFormat.Gif(animated = bytes.containsAscii("NETSCAPE2.0")) + } + + // BMP: "BM" + if (bytes.startsWith('B', 'M')) return ImageFormat.Bmp + + // TIFF: II*\0 (little-endian) or MM\0* (big-endian) + if (bytes.startsWith(0x49, 0x49, 0x2A, 0x00) || bytes.startsWith(0x4D, 0x4D, 0x00, 0x2A)) { + return ImageFormat.Tiff + } + + // RIFF container: WebP. "RIFF....WEBP". Animation requires VP8X + // chunk (byte after 'VP8X' tag, then 4-byte chunk size, then a + // single flags byte whose bit 1 = animation, OR the presence of + // an 'ANIM' chunk somewhere in the prefix. + if (bytes.size >= 12 && bytes.startsWith('R', 'I', 'F', 'F') && + bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() && + bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() + ) { + val hasAnim = bytes.containsAscii("ANIM") || webpHasAnimationFlag(bytes) + return ImageFormat.WebP(animated = hasAnim) + } + + // ISO Base Media File Format (ftyp box). Used by HEIC, AVIF. + // Layout: 4-byte size, "ftyp", 4-byte major brand, 4-byte minor + // version, then compatible brands. + if (bytes.size >= 12 && bytes[4] == 'f'.code.toByte() && bytes[5] == 't'.code.toByte() && + bytes[6] == 'y'.code.toByte() && bytes[7] == 'p'.code.toByte() + ) { + val brand = String(bytes, 8, 4) + return when (brand) { + "avif", "avis" -> ImageFormat.Avif + "heic", "heix", "heim", "heis", "hevc", "hevm", "hevs", "mif1", "msf1" -> ImageFormat.Heic + else -> ImageFormat.Unknown("application/octet-stream") + } + } + + // SVG: optional XML prolog then "= 3 && bytes[0] == 0xEF.toByte() && + bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte() + ) { + 3 + } else { + 0 + } + val text = + String(bytes, bomLen, minOf(bytes.size - bomLen, 1024), Charsets.US_ASCII) + .trimStart(' ', '\n', '\r', '\t') + if (text.startsWith(" this.size) return false + outer@ for (i in 0..this.size - needle.length) { + for (j in needle.indices) { + if (this[i + j] != needle[j].code.toByte()) continue@outer + } + return true + } + return false + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt new file mode 100644 index 0000000000..9e9aa717cf --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ImageFormatSnifferTest { + @Test + fun sniffsJpeg() { + val bytes = byteArrayOf(0xFF.b, 0xD8.b, 0xFF.b, 0xE0.b, 0x00, 0x10) + assertEquals(ImageFormat.Jpeg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsPng() { + val bytes = byteArrayOf(0x89.b, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D) + assertEquals(ImageFormat.Png, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsBmp() { + val bytes = byteArrayOf('B'.b, 'M'.b, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00) + assertEquals(ImageFormat.Bmp, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsTiffLittleEndian() { + val bytes = byteArrayOf(0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00) + assertEquals(ImageFormat.Tiff, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsTiffBigEndian() { + val bytes = byteArrayOf(0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08) + assertEquals(ImageFormat.Tiff, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsStaticGif() { + val bytes = ascii("GIF89a") + byteArrayOf(0x10, 0x00, 0x10, 0x00) + assertEquals(ImageFormat.Gif(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsGif87aStatic() { + val bytes = ascii("GIF87a") + byteArrayOf(0x10, 0x00, 0x10, 0x00) + assertEquals(ImageFormat.Gif(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedGifViaNetscapeExtension() { + val bytes = + ascii("GIF89a") + + ByteArray(50) { 0x00 } + + ascii("NETSCAPE2.0") + + ByteArray(20) { 0x00 } + assertEquals(ImageFormat.Gif(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsStaticWebpRiffOnly() { + val bytes = + ascii("RIFF") + byteArrayOf(0x10, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8L") + ByteArray(80) { 0x00 } + assertEquals(ImageFormat.WebP(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedWebpViaVp8xAnimFlag() { + // RIFF + size + WEBP + VP8X chunk header + size + flags(0x02 = anim) + val bytes = + ascii("RIFF") + byteArrayOf(0x20, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8X") + byteArrayOf(0x0A, 0x00, 0x00, 0x00) + + byteArrayOf(0x02.b) + ByteArray(40) { 0x00 } + assertEquals(ImageFormat.WebP(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedWebpViaAnimChunk() { + // RIFF + WEBP + ... + ANIM chunk somewhere in the prefix (no + // VP8X animation flag — covers files that put ANIM directly). + val bytes = + ascii("RIFF") + byteArrayOf(0x40, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8X") + byteArrayOf(0x0A, 0x00, 0x00, 0x00) + + byteArrayOf(0x00.b) + ByteArray(9) { 0x00 } + + ascii("ANIM") + ByteArray(30) { 0x00 } + assertEquals(ImageFormat.WebP(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAvifBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("avif") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Avif, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAvifSequenceBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("avis") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Avif, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsHeicBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("heic") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Heic, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsHeifMif1Brand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("mif1") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Heic, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsSvgWithXmlProlog() { + val bytes = ascii("""""") + assertEquals(ImageFormat.Svg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsSvgWithoutProlog() { + val bytes = ascii("""""") + assertEquals(ImageFormat.Svg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun returnsUnknownForRandomBytes() { + val bytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07) + assertTrue(ImageFormatSniffer.sniff(bytes) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForEmptyBytes() { + assertTrue(ImageFormatSniffer.sniff(ByteArray(0)) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForTooShort() { + assertTrue(ImageFormatSniffer.sniff(byteArrayOf(0xFF.b)) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForEmptyFile() { + val empty = File.createTempFile("amethyst_empty_", ".bin") + try { + assertTrue(ImageFormatSniffer.sniff(empty) is ImageFormat.Unknown) + } finally { + empty.delete() + } + } + + @Test + fun returnsUnknownForMissingFile() { + val missing = File("/nonexistent/path/never/exists/file.bin") + assertTrue(ImageFormatSniffer.sniff(missing) is ImageFormat.Unknown) + } + + @Test + fun sniffsJpegFromFile() { + val jpeg = File.createTempFile("amethyst_sniff_jpeg_", ".jpg") + try { + val bytes = byteArrayOf(0xFF.b, 0xD8.b, 0xFF.b, 0xE0.b) + ByteArray(200) { 0x00 } + jpeg.writeBytes(bytes) + assertEquals(ImageFormat.Jpeg, ImageFormatSniffer.sniff(jpeg)) + } finally { + jpeg.delete() + } + } + + // ----- tiny helpers ----- + + private val Int.b: Byte get() = this.toByte() + + private val Char.b: Byte get() = this.code.toByte() + + private fun ascii(s: String): ByteArray = s.toByteArray(Charsets.US_ASCII) +}