IJPL-155932 SVG Image support via ImageReaderSpi (detect SVG)

closes #2850

GitOrigin-RevId: 1674cb6711fa8e2744c09dcdd58963051d331f3c
This commit is contained in:
Brice Dutheil
2024-09-21 00:31:22 +02:00
committed by intellij-monorepo-bot
parent 783bbde096
commit f9f1a0aa07
5 changed files with 460 additions and 1 deletions

View File

@@ -42,5 +42,6 @@
<orderEntry type="library" name="kotlinx-serialization-json" level="project" />
<orderEntry type="module" module-name="intellij.xml.frontback" />
<orderEntry type="module" module-name="intellij.platform.ui.jcef" />
<orderEntry type="library" name="jsvg" level="project" />
</component>
</module>

View File

@@ -1 +1,2 @@
org.intellij.images.util.imageio.CommonsImagingImageReaderSpi
org.intellij.images.util.imageio.CommonsImagingImageReaderSpi
org.intellij.images.util.imageio.svg.SvgImageReaderSpi

View File

@@ -0,0 +1,144 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.intellij.images.util.imageio.svg
import com.github.weisj.jsvg.attributes.font.SVGFont
import com.github.weisj.jsvg.geometry.size.Length
import com.github.weisj.jsvg.nodes.SVG
import com.intellij.ui.paint.PaintUtil
import com.intellij.ui.scale.ScaleContext
import com.intellij.ui.svg.createJSvgDocument
import com.intellij.util.ui.ImageUtil
import java.awt.image.BufferedImage
import java.io.IOException
import java.io.InputStream
import javax.imageio.ImageReadParam
import javax.imageio.ImageReader
import javax.imageio.ImageTypeSpecifier
import javax.imageio.stream.ImageInputStream
import kotlin.math.max
class SvgImageReader(svgImageReaderSpi: SvgImageReaderSpi) : ImageReader(svgImageReaderSpi) {
private var height: Length? = null
private var width: Length? = null
private var jSvgDocument: SVG? = null
override fun setInput(input: Any?, seekForwardOnly: Boolean, ignoreMetadata: Boolean) {
super.setInput(input, seekForwardOnly, ignoreMetadata)
reset()
}
override fun getWidth(imageIndex: Int): Int {
loadInfoIfNeeded()
return width?.raw()?.toInt() ?: throw IOException("SVG document not loaded")
}
override fun getHeight(imageIndex: Int): Int {
loadInfoIfNeeded()
return height?.raw()?.toInt() ?: throw IOException("SVG document not loaded")
}
@Throws(IOException::class)
private fun loadInfoIfNeeded() {
val input = input
if (jSvgDocument == null && input is ImageInputStream) {
try {
// Not using ByteArray to avoid potential humongous allocation
ImageInputStreamAdapter(input).buffered().use {
jSvgDocument = createJSvgDocument(it).also { svg ->
width = svg.width
height = svg.height
}
}
} catch (e: IOException) {
// could not read the SVG document
}
}
}
@Throws(IOException::class)
override fun read(imageIndex: Int, param: ImageReadParam?): BufferedImage {
loadInfoIfNeeded()
jSvgDocument ?: throw IOException("SVG document not loaded")
val sourceRenderSize = param?.sourceRenderSize
val width = sourceRenderSize?.width?.toDouble() ?: width!!.raw().toDouble()
val height = sourceRenderSize?.height?.toDouble() ?: height!!.raw().toDouble()
// how to have an hidpi aware image?
val bi = ImageUtil.createImage(
ScaleContext.create(),
width,
height,
BufferedImage.TYPE_INT_ARGB,
PaintUtil.RoundingMode.ROUND
)
val g = bi.createGraphics()
ImageUtil.applyQualityRenderingHints(g)
jSvgDocument?.renderWithSize(
width.toFloat(),
height.toFloat(),
SVGFont.defaultFontSize(),
g,
)
return bi
}
override fun reset() {
jSvgDocument = null
height = null
width = null
}
override fun dispose() {
reset()
}
override fun getNumImages(allowSearch: Boolean): Int {
return if (jSvgDocument != null) 1 else 0
}
override fun getImageTypes(imageIndex: Int): Iterator<ImageTypeSpecifier> {
return listOf<ImageTypeSpecifier>(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_ARGB)).iterator()
}
override fun getStreamMetadata() = null
override fun getImageMetadata(imageIndex: Int) = null
}
private class ImageInputStreamAdapter(private val imageInputStream: ImageInputStream) : InputStream() {
private var closed = false
@Throws(IOException::class)
override fun close() {
closed = true
}
@Throws(IOException::class)
override fun read(): Int {
if(closed) throw IOException("stream closed")
return imageInputStream.read()
}
@Throws(IOException::class)
override fun read(b: ByteArray, off: Int, len: Int): Int {
if(closed) throw IOException("stream closed")
if (len <= 0) {
return 0
}
return imageInputStream.read(b, off, max(len.toLong(), 0).toInt())
}
@Throws(IOException::class)
override fun skip(n: Long): Long {
if(closed) throw IOException("stream closed")
if (n <= 0) {
return 0
}
return imageInputStream.skipBytes(n)
}
}

View File

@@ -0,0 +1,143 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.intellij.images.util.imageio.svg
import java.io.EOFException
import java.util.*
import javax.imageio.ImageReader
import javax.imageio.spi.ImageReaderSpi
import javax.imageio.stream.ImageInputStream
import kotlin.experimental.and
/**
* ImageReaderSpi for SVG images.
*
* @author Brice Dutheil
*/
class SvgImageReaderSpi : ImageReaderSpi() {
init {
vendorName = "weisj/jsvg & JetBrains"
suffixes = arrayOf("svg")
MIMETypes = arrayOf("image/svg+xml")
names = arrayOf("SVG Image Reader")
pluginClassName = SvgImageReaderSpi::class.java.name
inputTypes = arrayOf<Class<*>>(ImageInputStream::class.java)
}
override fun getDescription(locale: Locale?) = "SVG Image Reader"
override fun canDecodeInput(source: Any?): Boolean {
if (source !is ImageInputStream) return false
return canDecode(source)
}
private fun canDecode(imageInputStream: ImageInputStream): Boolean {
// NOTE: This test is quite quick as it does not involve any parsing,
// however, it may not recognize all kinds of SVG documents.
try {
// We need to read the first few bytes to determine if this is an SVG file,
// then reset the stream at the marked position
imageInputStream.mark()
// SVG file can starts with an XML declaration, then possibly followed by comments, whitespaces
// \w.*(<?xml version="1.0" encoding="UTF-8" ?>)?
// (\w|(<!--.*-->))*
// Then either the doctype gives the hint possibly surrounded by comments and/or whitespaces
// <!DOCTYPE svg ...
// (\w|(<!--.*-->))*
// Or the root tag is svg, possibly preceding comments and/or whitespaces
// <svg ...
// Handles first whitespaces if any
val lastReadByte = imageInputStream.readFirstAfterWhitespaces()
// The next byte should be '<' (comments, doctype XML declaration or the root tag)
if (lastReadByte != '<'.code) {
return false
}
val window = ByteArray(4)
while (true) {
imageInputStream.readFully(window)
when {
// `<?` Handles the XML declaration
window.startsWith('?') -> imageInputStream.skipUntil('?', '>')
//buffer[0] == '?'.code.toByte() -> imageInputStream.skipUntil('?', '>')
// `<!--` Handles a comment
window.startsWith('!', '-', '-') -> imageInputStream.skipUntil('-', '-', '>')
// `<!DOCTYPE` Handles the DOCTYPE declaration
window.startsWith('!', 'D', 'O', 'C') && imageInputStream.readNextEquals(charArrayOf('T', 'Y', 'P', 'E')) -> {
val lastReadChar = imageInputStream.readFirstAfterWhitespaces()
return lastReadChar == 's'.code && imageInputStream.readNextEquals(charArrayOf('v', 'g'))
}
// `<svg` Handles the root tag
window.startsWith('s', 'v', 'g') && (Char(window[3].toUShort()).isWhitespace() || window[3] == ':'.code.toByte()) -> {
return true
}
// not an SVG file or not handled
else -> return false
}
//while ((imageInputStream.readByte() and 0xFF.toByte()).toInt().toChar() != '<') {
// // Skip over, until next begin tag or EOF
//}
// Skip over, until next begin tag or EOF
imageInputStream.skipUntil('<')
}
} catch (ignore: EOFException) {
// Possible for small files...
ignore.printStackTrace()
return false
} finally {
imageInputStream.reset()
}
}
private fun ImageInputStream.readFirstAfterWhitespaces(): Int {
var lastReadByte: Int
while ((read().also { lastReadByte = it }).toChar().isWhitespace()) {
// skip whitespaces if any
}
return lastReadByte
}
private fun ImageInputStream.skipUntil(vararg expected: Char) {
while (!readNextEquals(expected)) {
// skip until expected chars or EOF
}
}
private fun ImageInputStream.readNextEquals(expected: CharArray): Boolean {
// first char is read with readByte()
require(expected.isNotEmpty())
val first = (readByte() and 0xFF.toByte()).toInt().toChar()
if (first != expected[0]) return false
// next can be read with read()
expected.forEachIndexed { index, it ->
if (index == 0) return@forEachIndexed
val read = read()
if (read != it.code) return false
}
return true
}
private fun ByteArray.startsWith(vararg expected: Char): Boolean {
expected.forEachIndexed { index, c ->
if (this[index] != c.code.toByte()) return false
}
return true
}
override fun createReaderInstance(extension: Any?): ImageReader {
return SvgImageReader(this)
}
}

View File

@@ -0,0 +1,170 @@
package org.intellij.images.util.imageio.svg
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import javax.imageio.stream.MemoryCacheImageInputStream
class SvgImageReaderSpiTest {
@Test
fun `can read svg with xml header`() {
val svg = """
<?xml version="1.0" encoding="UTF-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `cannot read svg with broken xml header`() {
val svg = """
<?xml version="1.0" encoding="UTF-8" // broken
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertFalse(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `cannot read svg with broken truncated file`() {
val svg = """
<?xml version="1.0" encoding="UTF-8" // broken
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertFalse(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg with whitespace before xml header`() {
val svg = """
<?xml version="1.0" encoding="UTF-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg with comment after xml header`() {
val svg = """
<?xml version="1.0" encoding="UTF-8" ?>
<!-- comment -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg with DOCTYPE after xml header`() {
val svg = """
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<!-- comment -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg with comment before DOCTYPE`() {
val svg = """
<!-- comment -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg starting with DOCTYPE`() {
val svg = """
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg starting with DOCTYPE having spaces`() {
val svg = """
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<!-- comment -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg starting with SVG root tag`() {
val svg = """
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `can read svg starting with comment then SVG root tag`() {
val svg = """
<!-- comment -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertTrue(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `cannot read svg with broken comment`() {
val svg = """
<!-- comment -- // broken
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertFalse(SvgImageReaderSpi().canDecodeInput(stream))
}
}
@Test
fun `cannot read svg with broken root tag`() {
val svg = """
<!-- comment -->
<sv xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> </svg>
""".trimIndent()
MemoryCacheImageInputStream(svg.byteInputStream()).use { stream ->
assertFalse(SvgImageReaderSpi().canDecodeInput(stream))
}
}
}