feat(commons): add image format sniffer + compression types

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.
This commit is contained in:
nrobi144
2026-06-09 11:41:59 +03:00
parent e17f04eb54
commit 0a4550af26
5 changed files with 581 additions and 0 deletions
@@ -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)
}
@@ -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
}
}
@@ -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()
}
@@ -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 "<svg". A lenient check that
// tolerates a leading UTF-8 BOM and whitespace.
val bomLen =
if (bytes.size >= 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("<?xml") || text.startsWith("<svg")) {
// Either an XML doc that contains <svg, or starts with <svg directly.
if (text.contains("<svg")) return ImageFormat.Svg
}
return ImageFormat.Unknown("application/octet-stream")
}
/**
* For RIFF/WebP: walk to the VP8X chunk (if present at the start
* of the container body) and read its single flags byte. Bit 1
* (value 0x02) means animation per RFC 9649.
*/
private fun webpHasAnimationFlag(bytes: ByteArray): Boolean {
// RIFF header: 12 bytes ("RIFF" + size + "WEBP"). The first
// chunk header is at offset 12: 4-byte FourCC + 4-byte size,
// then payload.
if (bytes.size < 21) return false
if (bytes[12] != 'V'.code.toByte() || bytes[13] != 'P'.code.toByte() ||
bytes[14] != '8'.code.toByte() || bytes[15] != 'X'.code.toByte()
) {
return false
}
// Skip the 4-byte size at [16..19]; flags byte is at [20].
return (bytes[20].toInt() and 0x02) != 0
}
private fun ByteArray.startsWith(vararg expected: Int): Boolean {
if (this.size < expected.size) return false
for (i in expected.indices) {
if (this[i] != expected[i].toByte()) return false
}
return true
}
private fun ByteArray.startsWith(vararg expected: Char): Boolean {
if (this.size < expected.size) return false
for (i in expected.indices) {
if (this[i] != expected[i].code.toByte()) return false
}
return true
}
private fun ByteArray.containsAscii(needle: String): Boolean {
if (needle.isEmpty() || needle.length > 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
}
}
@@ -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("""<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>""")
assertEquals(ImageFormat.Svg, ImageFormatSniffer.sniff(bytes))
}
@Test
fun sniffsSvgWithoutProlog() {
val bytes = ascii("""<svg xmlns="http://www.w3.org/2000/svg"></svg>""")
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)
}