This commit is contained in:
Vitor Pamplona
2026-04-20 09:38:12 -04:00
150 changed files with 8491 additions and 448 deletions
@@ -0,0 +1,26 @@
/*
* 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.thumbhash
import android.graphics.Bitmap
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
fun Bitmap.toThumbhash(): String = this.toPlatformImage().toThumbhash()
@@ -29,6 +29,7 @@ abstract class BaseMediaContent(
val description: String? = null,
val dim: DimensionTag? = null,
val blurhash: String? = null,
val thumbhash: String? = null,
)
@Immutable
@@ -40,7 +41,8 @@ abstract class MediaUrlContent(
blurhash: String? = null,
val uri: String? = null,
val mimeType: String? = null,
) : BaseMediaContent(description, dim, blurhash)
thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash)
@Immutable
open class MediaUrlImage(
@@ -52,7 +54,8 @@ open class MediaUrlImage(
uri: String? = null,
val contentWarning: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
thumbhash: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
class EncryptedMediaUrlImage(
url: String,
@@ -66,7 +69,20 @@ class EncryptedMediaUrlImage(
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
thumbhash: String? = null,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash)
@Immutable
open class MediaUrlPdf(
url: String,
description: String? = null,
hash: String? = null,
blurhash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
mimeType: String? = null,
thumbhash: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
@Immutable
open class MediaUrlVideo(
@@ -80,7 +96,8 @@ open class MediaUrlVideo(
blurhash: String? = null,
val contentWarning: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
thumbhash: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
@Immutable
class EncryptedMediaUrlVideo(
@@ -97,7 +114,8 @@ class EncryptedMediaUrlVideo(
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
thumbhash: String? = null,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash)
@Immutable
abstract class MediaPreloadedContent(
@@ -109,7 +127,8 @@ abstract class MediaPreloadedContent(
blurhash: String? = null,
val uri: String,
val id: String? = null,
) : BaseMediaContent(description, dim, blurhash) {
thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
fun localFileExists() = localFile != null && localFile.exists()
}
@@ -122,7 +141,8 @@ class MediaLocalImage(
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@Immutable
class MediaLocalVideo(
@@ -135,4 +155,5 @@ class MediaLocalVideo(
uri: String,
val artworkUri: String? = null,
val authorName: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -58,17 +59,21 @@ class RichTextParser {
val isImage: Boolean
val isVideo: Boolean
val isPdf: Boolean
if (contentType != null) {
isImage = contentType.startsWith("image/")
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
isPdf = contentType.startsWith("application/pdf")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
isPdf = fullUrl.startsWith("data:application/pdf")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
@@ -81,6 +86,7 @@ class RichTextParser {
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
)
} else if (isVideo) {
MediaUrlVideo(
@@ -92,6 +98,18 @@ class RichTextParser {
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
)
} else if (isPdf) {
MediaUrlPdf(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
)
} else {
null
@@ -158,6 +176,7 @@ class RichTextParser {
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
@@ -165,7 +184,7 @@ class RichTextParser {
val newContent = fixMissingSpaces(content, allUrls)
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags)
val mediaForPagerWithBase64 =
mediaForPager +
@@ -197,6 +216,7 @@ class RichTextParser {
content: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -211,7 +231,7 @@ class RichTextParser {
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
@@ -262,6 +282,7 @@ class RichTextParser {
word: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -288,6 +309,14 @@ class RichTextParser {
}
}
if (pdfs.contains(word)) {
return if (urls.withoutScheme.contains(word)) {
PdfSegment("https://$word")
} else {
PdfSegment(word)
}
}
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
if (urls.withScheme.contains(word)) return LinkSegment(word)
@@ -377,9 +406,11 @@ class RichTextParser {
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
val pdfExt = listOf("pdf")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
@@ -421,6 +452,11 @@ class RichTextParser {
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isPdfUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
try {
if (url != null) {
@@ -496,4 +532,6 @@ val mimeTypeMap: Map<String, String> =
"m4a" to "audio/mp4",
"aac" to "audio/aac",
"flac" to "audio/flac",
// Documents
"pdf" to "application/pdf",
)
@@ -62,6 +62,11 @@ class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class PdfSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
@@ -0,0 +1,406 @@
/*
* 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.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.round
/**
* ThumbHash decoder.
*
* Port of the reference implementation by Evan Wallace
* (https://github.com/evanw/thumbhash, public domain), with performance
* optimisations for the decode hot path:
*
* - cosine tables for the inverse DCT are precomputed once per decode and
* cached across decodes keyed by `(size, componentCount)`; the reference
* JS impl recomputes them for every single output pixel.
* - AC coefficients are unpacked into fixed-size `DoubleArray`s, avoiding
* `ArrayList<Double>` boxing and the final array copy.
* - The LPQA → sRGB conversion uses an inline branch clamp instead of
* `min/max/round/coerceIn` chains.
*/
object ThumbHashDecoder {
// Cosine tables are small and decoded sizes repeat heavily in practice
// (every Coil request at a given target width shares the same table).
// Keep an unbounded map — there are at most a few dozen distinct
// (size, components) pairs across the entire app lifetime, each table is
// a few KB, so the memory ceiling is tiny.
private val cosineCache = HashMap<Long, DoubleArray>()
private val cosineCacheLock = Any()
/**
* Clear the cosine table cache. Tables are tiny but callers under memory
* pressure can release them; they will be recomputed on demand.
*/
fun clearCache() {
synchronized(cosineCacheLock) { cosineCache.clear() }
}
private fun cosTable(
size: Int,
components: Int,
): DoubleArray {
val key = (size.toLong() shl 32) or components.toLong()
synchronized(cosineCacheLock) {
cosineCache[key]?.let { return it }
}
val table = DoubleArray(size * components)
val piOverSize = PI / size
for (i in 0 until size) {
val phase = piOverSize * (i + 0.5)
val rowOffset = i * components
for (c in 0 until components) {
table[rowOffset + c] = cos(phase * c)
}
}
synchronized(cosineCacheLock) {
cosineCache.getOrPut(key) { table }
}
return table
}
/**
* Returns width/height. Returns null if the hash is malformed.
*/
fun aspectRatio(hash: ByteArray): Float? {
if (hash.size < 5) return null
val header = hash[3].toInt() and 0xff
val hasAlpha = (hash[2].toInt() and 0x80) != 0
val isLandscape = (hash[4].toInt() and 0x80) != 0
val lx = if (isLandscape) (if (hasAlpha) 5 else 7) else (header and 7)
val ly = if (isLandscape) (header and 7) else (if (hasAlpha) 5 else 7)
if (lx == 0 || ly == 0) return null
return lx.toFloat() / ly.toFloat()
}
/**
* Returns width/height. Returns null if the string is malformed.
*/
@OptIn(ExperimentalEncodingApi::class)
fun aspectRatio(base64Hash: String?): Float? {
if (base64Hash.isNullOrBlank()) return null
return try {
aspectRatio(Base64.decode(padBase64(base64Hash)))
} catch (_: IllegalArgumentException) {
null
}
}
data class RGBAImage(
val width: Int,
val height: Int,
val pixels: IntArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RGBAImage) return false
return width == other.width && height == other.height && pixels.contentEquals(other.pixels)
}
override fun hashCode(): Int {
var result = width
result = 31 * result + height
result = 31 * result + pixels.contentHashCode()
return result
}
}
/**
* Decode a ThumbHash byte array into ARGB pixels.
* Returns null if the hash is malformed.
*/
fun decode(hash: ByteArray): RGBAImage? {
if (hash.size < 5) return null
val b0 = hash[0].toInt() and 0xff
val b1 = hash[1].toInt() and 0xff
val b2 = hash[2].toInt() and 0xff
val b3 = hash[3].toInt() and 0xff
val b4 = hash[4].toInt() and 0xff
val header24 = b0 or (b1 shl 8) or (b2 shl 16)
val header16 = b3 or (b4 shl 8)
val lDc = (header24 and 63) / 63.0
val pDc = ((header24 shr 6) and 63) / 31.5 - 1.0
val qDc = ((header24 shr 12) and 63) / 31.5 - 1.0
val lScale = ((header24 shr 18) and 31) / 31.0
val hasAlpha = (header24 shr 23) and 1 == 1
val pScale = ((header16 shr 3) and 63) / 63.0
val qScale = ((header16 shr 9) and 63) / 63.0
val isLandscape = (header16 shr 15) and 1 == 1
val lx = max(3, if (isLandscape) (if (hasAlpha) 5 else 7) else (header16 and 7))
val ly = max(3, if (isLandscape) (header16 and 7) else (if (hasAlpha) 5 else 7))
val aDc: Double
val aScale: Double
if (hasAlpha) {
if (hash.size < 6) return null
aDc = (hash[5].toInt() and 15) / 15.0
aScale = ((hash[5].toInt() shr 4) and 15) / 15.0
} else {
aDc = 1.0
aScale = 0.0
}
// Pre-size and unpack AC coefficients
val lAcCount = countAc(lx, ly)
val pqAcCount = countAc(3, 3)
val aAcCount = if (hasAlpha) countAc(5, 5) else 0
val totalAc = lAcCount + pqAcCount * 2 + aAcCount
val acStart = if (hasAlpha) 6 else 5
val acBytesAvailable = hash.size - acStart
// 2 coefficients per byte
if (acBytesAvailable * 2 < totalAc) return null
val lAc = DoubleArray(lAcCount)
val pAc = DoubleArray(pqAcCount)
val qAc = DoubleArray(pqAcCount)
val aAc = if (hasAlpha) DoubleArray(aAcCount) else EMPTY_DOUBLE
var acIndex = 0
acIndex = readAcInto(hash, acStart, acIndex, lx, ly, lScale, lAc)
acIndex = readAcInto(hash, acStart, acIndex, 3, 3, pScale * 1.25, pAc)
acIndex = readAcInto(hash, acStart, acIndex, 3, 3, qScale * 1.25, qAc)
if (hasAlpha) readAcInto(hash, acStart, acIndex, 5, 5, aScale, aAc)
// Output size
val ratio = lx.toDouble() / ly.toDouble()
val w = round(if (ratio > 1) 32.0 else 32.0 * ratio).toInt()
val h = round(if (ratio > 1) 32.0 / ratio else 32.0).toInt()
val pixels = IntArray(w * h)
// Precomputed cosine tables (shared across decodes with matching size/components)
val cosXL = cosTable(w, lx)
val cosYL = cosTable(h, ly)
val cosXPQ = cosTable(w, 3)
val cosYPQ = cosTable(h, 3)
val cosXA: DoubleArray
val cosYA: DoubleArray
if (hasAlpha) {
cosXA = cosTable(w, 5)
cosYA = cosTable(h, 5)
} else {
cosXA = EMPTY_DOUBLE
cosYA = EMPTY_DOUBLE
}
// Decode pixels using the inverse DCT
var pixelIdx = 0
for (y in 0 until h) {
val cosYLBase = y * ly
val cosYPQBase = y * 3
val cosYABase = y * 5
for (x in 0 until w) {
val cosXLBase = x * lx
val cosXPQBase = x * 3
val cosXABase = x * 5
var l = lDc
var p = pDc
var q = qDc
var a = aDc
// L channel — triangular iteration over (cx, cy)
var j = 0
var cy = 0
while (cy < ly) {
val fyL2 = cosYL[cosYLBase + cy] * 2.0
var cx = if (cy != 0) 0 else 1
val cxLimit = cxLimitForL(lx, ly, cy)
while (cx < cxLimit) {
l += lAc[j] * cosXL[cosXLBase + cx] * fyL2
j++
cx++
}
cy++
}
// P and Q share the same 3x3 triangular iteration
j = 0
cy = 0
while (cy < 3) {
val fyPQ2 = cosYPQ[cosYPQBase + cy] * 2.0
var cx = if (cy != 0) 0 else 1
val cxLimit = 3 - cy
while (cx < cxLimit) {
val f = cosXPQ[cosXPQBase + cx] * fyPQ2
p += pAc[j] * f
q += qAc[j] * f
j++
cx++
}
cy++
}
// Alpha channel
if (hasAlpha) {
j = 0
cy = 0
while (cy < 5) {
val fyA2 = cosYA[cosYABase + cy] * 2.0
var cx = if (cy != 0) 0 else 1
val cxLimit = 5 - cy
while (cx < cxLimit) {
a += aAc[j] * cosXA[cosXABase + cx] * fyA2
j++
cx++
}
cy++
}
}
// LPQA → sRGB with inline clamp
val bCh = l - 2.0 / 3.0 * p
val rCh = (3.0 * l - bCh + q) * 0.5
val gCh = rCh - q
val rOut = clamp255(rCh)
val gOut = clamp255(gCh)
val bOut = clamp255(bCh)
val aOut = if (hasAlpha) clamp255(a) else 255
pixels[pixelIdx++] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut
}
}
return RGBAImage(w, h, pixels)
}
/**
* Decode a base64-encoded ThumbHash string to ARGB pixels.
*/
@OptIn(ExperimentalEncodingApi::class)
fun decode(base64Hash: String?): RGBAImage? {
if (base64Hash.isNullOrBlank()) return null
return try {
decode(Base64.decode(padBase64(base64Hash)))
} catch (_: IllegalArgumentException) {
null
}
}
/**
* Decode a ThumbHash string into a [PlatformImage] whose aspect ratio
* matches the original image. [targetWidth] is accepted for API symmetry
* with [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio]
* but the intrinsic decode output size is used because ThumbHash's own
* reconstruction is already aspect-correct at ~32px.
*/
@Suppress("UNUSED_PARAMETER")
fun decodeKeepAspectRatio(
hash: String?,
targetWidth: Int,
): PlatformImage? {
val rgba = decode(hash) ?: return null
return PlatformImage.create(rgba.pixels, rgba.width, rgba.height)
}
// --- internal helpers --- //
private val EMPTY_DOUBLE = DoubleArray(0)
/**
* Count the number of AC coefficients carried by a channel of size nx × ny,
* following the reference implementation's triangular traversal.
*/
private fun countAc(
nx: Int,
ny: Int,
): Int {
var count = 0
var cy = 0
while (cy < ny) {
var cx = if (cy != 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
count++
cx++
}
cy++
}
return count
}
/**
* Row limit for the L channel's triangular traversal. For nx == ny this
* collapses to `nx - cy`; keeping the explicit form avoids a mispredicted
* branch in the inner loop for non-square L blocks.
*/
private fun cxLimitForL(
lx: Int,
ly: Int,
cy: Int,
): Int {
// cx * ly < lx * (ly - cy) ⇔ cx < (lx * (ly - cy)) / ly
// Use integer ceil emulation: smallest cx that fails the condition.
val numerator = lx * (ly - cy)
// Largest cx satisfying cx * ly < numerator:
// cx <= ceil(numerator / ly) - 1 when numerator is exact,
// otherwise cx <= floor(numerator / ly).
// So the limit (exclusive) is ceil(numerator / ly) when numerator % ly != 0,
// else numerator / ly.
return if (numerator % ly == 0) numerator / ly else numerator / ly + 1
}
private fun readAcInto(
hash: ByteArray,
acStart: Int,
startIndex: Int,
nx: Int,
ny: Int,
scale: Double,
out: DoubleArray,
): Int {
var acIndex = startIndex
var outIdx = 0
val hashLen = hash.size
var cy = 0
while (cy < ny) {
var cx = if (cy != 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
val byteIdx = acStart + (acIndex shr 1)
if (byteIdx >= hashLen) return acIndex
val shift = (acIndex and 1) shl 2
val q4 = (hash[byteIdx].toInt() ushr shift) and 15
out[outIdx++] = (q4 / 7.5 - 1.0) * scale
acIndex++
cx++
}
cy++
}
return acIndex
}
/** Clamp v into 0..1 and scale to 0..255 with rounding, branchlessly on the hot path. */
private fun clamp255(v: Double): Int {
if (v <= 0.0) return 0
if (v >= 1.0) return 255
return (v * 255.0 + 0.5).toInt()
}
private fun padBase64(s: String): String {
val remainder = s.length % 4
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
}
}
@@ -0,0 +1,218 @@
/*
* 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.thumbhash
import kotlin.io.encoding.Base64
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.round
/**
* ThumbHash encoder.
*
* Port of the reference implementation by Evan Wallace
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
*
* Input pixels are ARGB (0xAARRGGBB) to match [com.vitorpamplona.amethyst.commons.blurhash.PlatformImage.getPixels].
*/
object ThumbHashEncoder {
/**
* Encodes an ARGB image to a ThumbHash byte array.
*
* @param pixels ARGB pixels (0xAARRGGBB), row-by-row; must contain [width] * [height] entries.
* @param width Image width in pixels; must be ≤ 100.
* @param height Image height in pixels; must be ≤ 100.
*/
fun encode(
pixels: IntArray,
width: Int,
height: Int,
): ByteArray {
require(width in 1..100 && height in 1..100) { "ThumbHash input must be ≤100x100 (got ${width}x$height)" }
require(pixels.size == width * height) { "pixels.size must equal width * height" }
// Average color (premultiplied by alpha)
var avgR = 0.0
var avgG = 0.0
var avgB = 0.0
var avgA = 0.0
for (i in 0 until width * height) {
val argb = pixels[i]
val alpha = ((argb ushr 24) and 0xff) / 255.0
val r = (argb ushr 16) and 0xff
val g = (argb ushr 8) and 0xff
val b = argb and 0xff
avgR += alpha / 255.0 * r
avgG += alpha / 255.0 * g
avgB += alpha / 255.0 * b
avgA += alpha
}
if (avgA > 0.0) {
avgR /= avgA
avgG /= avgA
avgB /= avgA
}
val hasAlpha = avgA < width * height
val lLimit = if (hasAlpha) 5 else 7
val lx = max(1, round(lLimit * width.toDouble() / max(width, height)).toInt())
val ly = max(1, round(lLimit * height.toDouble() / max(width, height)).toInt())
val size = width * height
val l = DoubleArray(size) // luminance
val p = DoubleArray(size) // yellow - blue
val q = DoubleArray(size) // red - green
val a = DoubleArray(size) // alpha
// Convert ARGB to LPQA, composited over the average color
for (i in 0 until size) {
val argb = pixels[i]
val alpha = ((argb ushr 24) and 0xff) / 255.0
val rPx = (argb ushr 16) and 0xff
val gPx = (argb ushr 8) and 0xff
val bPx = argb and 0xff
val r = avgR * (1 - alpha) + alpha / 255.0 * rPx
val g = avgG * (1 - alpha) + alpha / 255.0 * gPx
val b = avgB * (1 - alpha) + alpha / 255.0 * bPx
l[i] = (r + g + b) / 3.0
p[i] = (r + g) / 2.0 - b
q[i] = r - g
a[i] = alpha
}
val lEnc = encodeChannel(l, width, height, max(3, lx), max(3, ly))
val pEnc = encodeChannel(p, width, height, 3, 3)
val qEnc = encodeChannel(q, width, height, 3, 3)
val aEnc = if (hasAlpha) encodeChannel(a, width, height, 5, 5) else null
val isLandscape = width > height
val lCount = if (isLandscape) ly else lx
val hasAlphaBit = if (hasAlpha) 1 else 0
val isLandscapeBit = if (isLandscape) 1 else 0
val header24 =
round(63.0 * lEnc.dc).toInt() or
(round(31.5 + 31.5 * pEnc.dc).toInt() shl 6) or
(round(31.5 + 31.5 * qEnc.dc).toInt() shl 12) or
(round(31.0 * lEnc.scale).toInt() shl 18) or
(hasAlphaBit shl 23)
val header16 =
lCount or
(round(63.0 * pEnc.scale).toInt() shl 3) or
(round(63.0 * qEnc.scale).toInt() shl 9) or
(isLandscapeBit shl 15)
val acChannels = if (hasAlpha) listOf(lEnc.ac, pEnc.ac, qEnc.ac, aEnc!!.ac) else listOf(lEnc.ac, pEnc.ac, qEnc.ac)
val totalAc = acChannels.sumOf { it.size }
val acStart = if (hasAlpha) 6 else 5
val hashSize = acStart + ((totalAc + 1) / 2)
val hash = ByteArray(hashSize)
hash[0] = (header24 and 0xff).toByte()
hash[1] = ((header24 ushr 8) and 0xff).toByte()
hash[2] = ((header24 ushr 16) and 0xff).toByte()
hash[3] = (header16 and 0xff).toByte()
hash[4] = ((header16 ushr 8) and 0xff).toByte()
if (hasAlpha) {
val aDcQ = round(15.0 * aEnc!!.dc).toInt() and 0xf
val aScaleQ = round(15.0 * aEnc.scale).toInt() and 0xf
hash[5] = (aDcQ or (aScaleQ shl 4)).toByte()
}
var acIndex = 0
for (ac in acChannels) {
for (f in ac) {
val q4 = round(15.0 * f).toInt() and 0xf
val byteIdx = acStart + (acIndex shr 1)
val shift = (acIndex and 1) shl 2
hash[byteIdx] = (hash[byteIdx].toInt() or (q4 shl shift)).toByte()
acIndex++
}
}
return hash
}
/**
* Encodes an ARGB image to a base64 ThumbHash string (no padding).
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
fun encodeToBase64(
pixels: IntArray,
width: Int,
height: Int,
): String = Base64.encode(encode(pixels, width, height)).trimEnd('=')
private data class ChannelEncoded(
val dc: Double,
val ac: DoubleArray,
val scale: Double,
)
private fun encodeChannel(
channel: DoubleArray,
w: Int,
h: Int,
nx: Int,
ny: Int,
): ChannelEncoded {
var dc = 0.0
var scale = 0.0
val acList = ArrayList<Double>((nx * ny))
val fx = DoubleArray(w)
var cy = 0
while (cy < ny) {
var cx = 0
while (cx * ny < nx * (ny - cy)) {
var f = 0.0
for (x in 0 until w) {
fx[x] = cos(PI / w * cx * (x + 0.5))
}
for (y in 0 until h) {
val fy = cos(PI / h * cy * (y + 0.5))
for (x in 0 until w) {
f += channel[x + y * w] * fx[x] * fy
}
}
f /= (w * h).toDouble()
if (cx != 0 || cy != 0) {
acList.add(f)
if (abs(f) > scale) scale = abs(f)
} else {
dc = f
}
cx++
}
cy++
}
val ac = DoubleArray(acList.size) { acList[it] }
if (scale != 0.0) {
for (i in ac.indices) {
ac[i] = 0.5 + 0.5 / scale * ac[i]
}
}
return ChannelEncoded(dc, ac, scale)
}
}
@@ -0,0 +1,52 @@
/*
* 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.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
/**
* Encodes this [PlatformImage] to a base64 ThumbHash string (no padding).
*
* ThumbHash is specified at ≤100x100. Larger images are downscaled first.
*/
fun PlatformImage.toThumbhash(): String {
val source =
if (width > 100 || height > 100) {
val aspect = width.toDouble() / height.toDouble()
val scaled =
if (width >= height) {
val w = 100
val h = (100.0 / aspect).toInt().coerceAtLeast(1)
this.scale(w, h)
} else {
val h = 100
val w = (100.0 * aspect).toInt().coerceAtLeast(1)
this.scale(w, h)
}
scaled
} else {
this
}
val pixels = IntArray(source.width * source.height)
source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height)
return ThumbHashEncoder.encodeToBase64(pixels, source.width, source.height)
}
@@ -0,0 +1,251 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
class ThumbHashTest {
@Test
fun `encode and decode a solid color produces a hash with matching aspect`() {
val w = 32
val h = 24
val pixels = IntArray(w * h) { 0xFFFF8040.toInt() } // opaque warm orange
val hashBytes = ThumbHashEncoder.encode(pixels, w, h)
assertTrue("hash should have at least header bytes", hashBytes.size >= 5)
val decoded = ThumbHashDecoder.decode(hashBytes)
assertNotNull(decoded)
decoded!!
assertTrue("decoded width should be positive", decoded.width > 0)
assertTrue("decoded height should be positive", decoded.height > 0)
val originalRatio = w.toFloat() / h.toFloat()
val decodedRatio = decoded.width.toFloat() / decoded.height.toFloat()
// ThumbHash loses some precision, but landscape vs portrait should be preserved.
assertTrue(
"decoded ratio ($decodedRatio) should be on the same side of 1 as original ($originalRatio)",
(originalRatio > 1f) == (decodedRatio > 1f) || originalRatio == decodedRatio,
)
}
@Test
fun `base64 round-trip preserves decoded dimensions`() {
val w = 40
val h = 40
// A simple gradient so the image isn't entirely flat.
val pixels =
IntArray(w * h) { i ->
val x = i % w
val y = i / w
val r = (x * 255 / (w - 1))
val g = (y * 255 / (h - 1))
(0xFF shl 24) or (r shl 16) or (g shl 8) or 0x40
}
val encoded = ThumbHashEncoder.encodeToBase64(pixels, w, h)
assertTrue("base64 string should be non-empty", encoded.isNotEmpty())
assertTrue("base64 string should not contain padding", !encoded.contains('='))
val viaBase64 = ThumbHashDecoder.decode(encoded)
assertNotNull(viaBase64)
viaBase64!!
val viaBytes = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(viaBytes)
viaBytes!!
assertEquals("base64 path and raw path should agree on width", viaBytes.width, viaBase64.width)
assertEquals("base64 path and raw path should agree on height", viaBytes.height, viaBase64.height)
}
@Test
fun `decoding a malformed hash returns null`() {
assertNull(ThumbHashDecoder.decode(ByteArray(3)))
assertNull(ThumbHashDecoder.decode(null as String?))
assertNull(ThumbHashDecoder.decode(""))
}
@Test
fun `decoded opaque image has fully opaque alpha`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFF8080FF.toInt() } // opaque cornflower-ish
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
for (p in decoded.pixels) {
val a = (p ushr 24) and 0xff
assertEquals("alpha should be 255 for opaque encode", 255, a)
}
}
@Test
fun `decoded transparent image preserves alpha channel`() {
val w = 32
val h = 32
// Fully transparent pixels everywhere.
val pixels = IntArray(w * h) { 0x00000000 }
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
// The average alpha is 0, so every decoded alpha should be at or near 0.
var maxAlpha = 0
for (p in decoded.pixels) {
val a = (p ushr 24) and 0xff
if (a > maxAlpha) maxAlpha = a
}
assertTrue("max alpha of all-transparent decode should be low; got $maxAlpha", maxAlpha <= 16)
}
@Test
fun `decoded average color is close to input average`() {
val w = 48
val h = 32
val target = intArrayOf(200, 120, 60) // warm orange
val pixels =
IntArray(w * h) {
(0xFF shl 24) or (target[0] shl 16) or (target[1] shl 8) or target[2]
}
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
var sumR = 0
var sumG = 0
var sumB = 0
for (p in decoded.pixels) {
sumR += (p shr 16) and 0xff
sumG += (p shr 8) and 0xff
sumB += p and 0xff
}
val count = decoded.pixels.size
val avgR = sumR / count
val avgG = sumG / count
val avgB = sumB / count
// ThumbHash quantisation allows a handful of codepoints of drift.
assertTrue("avg R drift: expected ${target[0]}, got $avgR", abs(avgR - target[0]) < 8)
assertTrue("avg G drift: expected ${target[1]}, got $avgG", abs(avgG - target[1]) < 8)
assertTrue("avg B drift: expected ${target[2]}, got $avgB", abs(avgB - target[2]) < 8)
}
@Test
fun `aspect ratio matches landscape input`() {
val w = 60
val h = 30
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val ratio = ThumbHashDecoder.aspectRatio(hash)
assertNotNull(ratio)
assertTrue("landscape ratio should be > 1, got $ratio", ratio!! > 1f)
}
@Test
fun `aspect ratio matches portrait input`() {
val w = 30
val h = 60
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val ratio = ThumbHashDecoder.aspectRatio(hash)
assertNotNull(ratio)
assertTrue("portrait ratio should be < 1, got $ratio", ratio!! < 1f)
}
@Test
fun `repeated decodes produce identical output (cosine cache determinism)`() {
val w = 40
val h = 30
val pixels =
IntArray(w * h) { i ->
val x = i % w
(0xFF shl 24) or (x * 6 shl 16) or ((i % 255) shl 8) or ((i * 3) and 0xff)
}
val hash = ThumbHashEncoder.encode(pixels, w, h)
val first = ThumbHashDecoder.decode(hash)
val second = ThumbHashDecoder.decode(hash)
val third = ThumbHashDecoder.decode(hash)
assertNotNull(first)
assertNotNull(second)
assertNotNull(third)
// Bit-exact: the cached cosine tables must produce identical output.
assertEquals(first, second)
assertEquals(first, third)
}
@Test
fun `clearCache does not affect correctness of subsequent decodes`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFFAABBCC.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val before = ThumbHashDecoder.decode(hash)
ThumbHashDecoder.clearCache()
val after = ThumbHashDecoder.decode(hash)
assertEquals(before, after)
}
@Test
fun `truncated AC payload returns null`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFF336699.toInt() }
val fullHash = ThumbHashEncoder.encode(pixels, w, h)
// Chop off half the AC payload.
val truncated = fullHash.copyOfRange(0, 5 + (fullHash.size - 5) / 4)
assertNull(
"hash with insufficient AC bytes should be rejected",
ThumbHashDecoder.decode(truncated),
)
}
@Test
fun `decoded output size stays within 32 x 32 bounds`() {
val w = 50
val h = 40
val pixels =
IntArray(w * h) { i ->
(0xFF shl 24) or ((i and 0xff) shl 16) or (((i * 2) and 0xff) shl 8) or ((i * 3) and 0xff)
}
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
assertTrue(
"expected output to fit in 32x32, got ${decoded.width}x${decoded.height}",
decoded.width in 1..32 && decoded.height in 1..32,
)
assertEquals(
"pixel buffer size must match dimensions",
decoded.width * decoded.height,
decoded.pixels.size,
)
}
}
@@ -0,0 +1,69 @@
/*
* 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.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PdfParserTest {
@Test
fun detectsPdfByExtension() {
val url = "https://example.com/docs/paper.pdf"
val state = RichTextParser().parseText(url, EmptyTagList, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL")
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}")
assertEquals(url, segment.segmentText)
}
@Test
fun detectsPdfFromImetaMimeTypeWithoutExtension() {
val url = "https://files.example.com/abcd1234"
val tags =
ImmutableListOfLists(
arrayOf(
arrayOf("imeta", "url $url", "m application/pdf"),
),
)
val state = RichTextParser().parseText(url, tags, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag")
assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType)
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}")
}
@Test
fun isPdfUrlHelperMatchesPdfExtension() {
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc"))
}
}
@@ -0,0 +1,26 @@
/*
* 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.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import java.awt.image.BufferedImage
fun BufferedImage.toThumbhash(): String = this.toPlatformImage().toThumbhash()