mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+60
@@ -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)
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+88
@@ -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()
|
||||
}
|
||||
+172
@@ -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
|
||||
}
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user