Add full SVG support

This commit is contained in:
bxdxnn
2026-06-14 10:06:17 +00:00
parent 88d419c03f
commit 5b53686c3e
11 changed files with 173 additions and 6 deletions
@@ -12,6 +12,7 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Xml
import androidx.exifinterface.media.ExifInterface
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.File
import java.io.InputStream
@@ -67,7 +69,10 @@ class AndroidMediaPreProcessor(
*/
private const val IMAGE_SCALE_REF_SIZE = 640
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
private const val SVG_DEFAULT_WIDTH = 640L
private const val SVG_DEFAULT_HEIGHT = 480L
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg)
}
private val contentResolver = context.contentResolver
@@ -156,6 +161,11 @@ class AndroidMediaPreProcessor(
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
if (mimeType == MimeTypes.Svg) {
return processSvgImage(uri, mimeType)
}
suspend fun processImageWithCompression(): MediaUploadInfo {
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
val orientation = contentResolver.openInputStream(uri).use { input ->
@@ -216,6 +226,76 @@ class AndroidMediaPreProcessor(
}
}
private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo {
Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}")
val file = copyToTmpFile(uri)
val (width, height) = extractSvgDimensions(file)
val imageInfo = ImageInfo(
width = width,
height = height,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
return MediaUploadInfo.Image(
file = file,
imageInfo = imageInfo,
thumbnailFile = null,
)
}
private fun extractSvgDimensions(file: File): Pair<Long, Long> {
return file.inputStream().use { inputStream ->
try {
val parser = Xml.newPullParser()
parser.setInput(inputStream, null)
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) {
val width = parser.getAttributeValue(null, "width")
val height = parser.getAttributeValue(null, "height")
val viewBox = parser.getAttributeValue(null, "viewBox")
val parsedWidth = width?.let { parseSvgLength(it) }
val parsedHeight = height?.let { parseSvgLength(it) }
if (parsedWidth != null && parsedHeight != null) {
return parsedWidth to parsedHeight
}
if (viewBox != null) {
val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() }
if (parts.size == 4 && parts[2] != null && parts[3] != null) {
val vbWidth = parts[2]!!.toLong().coerceAtLeast(1)
val vbHeight = parts[3]!!.toLong().coerceAtLeast(1)
return vbWidth to vbHeight
}
}
return (parsedWidth ?: SVG_DEFAULT_WIDTH) to (parsedHeight ?: SVG_DEFAULT_HEIGHT)
}
eventType = parser.next()
}
SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT
} catch (_: Exception) {
SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT
}
}
}
private fun parseSvgLength(value: String): Long? {
val trimmed = value.trim()
if (trimmed.isEmpty()) return null
return try {
val numericPart = trimmed.replace(Regex("[^\\d.]"), "")
if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1)
} catch (_: NumberFormatException) {
null
}
}
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
Timber.d("Processing video ${uri.path.orEmpty().hash()}")
val resultFile = runCatchingExceptions {
Binary file not shown.
@@ -401,6 +401,54 @@ class AndroidMediaPreProcessorTest {
)
}
@Test
fun `test processing svg`() = runTest {
val mediaUploadInfo = process(
asset = assetImageSvg,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
width = 800,
height = 600,
mimetype = MimeTypes.Svg,
size = 210,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(info.thumbnailFile).isNull()
}
@Test
fun `test processing svg without compression`() = runTest {
val mediaUploadInfo = process(
asset = assetImageSvg,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
width = 800,
height = 600,
mimetype = MimeTypes.Svg,
size = 210,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(info.thumbnailFile).isNull()
}
@Test
fun `test processing audio`() = runTest {
val mediaUploadInfo = process(
@@ -83,3 +83,14 @@ val assetAnimatedGif = Asset(
width = 800,
height = 600,
)
/**
* "image.svg" is an 800 x 600 SVG image with a size of 210 bytes.
*/
val assetImageSvg = Asset(
filename = "image.svg",
mimeType = MimeTypes.Svg,
size = 210,
width = 800,
height = 600,
)