From f9f1a0aa07e43fd0ea0ca211f04bc28d6f71ba44 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Sat, 21 Sep 2024 00:31:22 +0200 Subject: [PATCH] IJPL-155932 SVG Image support via ImageReaderSpi (detect SVG) closes #2850 GitOrigin-RevId: 1674cb6711fa8e2744c09dcdd58963051d331f3c --- images/intellij.platform.images.iml | 1 + .../services/javax.imageio.spi.ImageReaderSpi | 3 +- .../images/util/imageio/svg/SvgImageReader.kt | 144 +++++++++++++++ .../util/imageio/svg/SvgImageReaderSpi.kt | 143 +++++++++++++++ .../util/imageio/svg/SvgImageReaderSpiTest.kt | 170 ++++++++++++++++++ 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 images/src/org/intellij/images/util/imageio/svg/SvgImageReader.kt create mode 100644 images/src/org/intellij/images/util/imageio/svg/SvgImageReaderSpi.kt create mode 100644 images/test/org/intellij/images/util/imageio/svg/SvgImageReaderSpiTest.kt diff --git a/images/intellij.platform.images.iml b/images/intellij.platform.images.iml index 345b5be762d6..e59d92929370 100644 --- a/images/intellij.platform.images.iml +++ b/images/intellij.platform.images.iml @@ -42,5 +42,6 @@ + \ No newline at end of file diff --git a/images/resources/META-INF/services/javax.imageio.spi.ImageReaderSpi b/images/resources/META-INF/services/javax.imageio.spi.ImageReaderSpi index c22125678d82..52a4d9483612 100644 --- a/images/resources/META-INF/services/javax.imageio.spi.ImageReaderSpi +++ b/images/resources/META-INF/services/javax.imageio.spi.ImageReaderSpi @@ -1 +1,2 @@ -org.intellij.images.util.imageio.CommonsImagingImageReaderSpi \ No newline at end of file +org.intellij.images.util.imageio.CommonsImagingImageReaderSpi +org.intellij.images.util.imageio.svg.SvgImageReaderSpi \ No newline at end of file diff --git a/images/src/org/intellij/images/util/imageio/svg/SvgImageReader.kt b/images/src/org/intellij/images/util/imageio/svg/SvgImageReader.kt new file mode 100644 index 000000000000..ade4111a071a --- /dev/null +++ b/images/src/org/intellij/images/util/imageio/svg/SvgImageReader.kt @@ -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 { + return listOf(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) + } +} \ No newline at end of file diff --git a/images/src/org/intellij/images/util/imageio/svg/SvgImageReaderSpi.kt b/images/src/org/intellij/images/util/imageio/svg/SvgImageReaderSpi.kt new file mode 100644 index 000000000000..7d2d5f6164c2 --- /dev/null +++ b/images/src/org/intellij/images/util/imageio/svg/SvgImageReaderSpi.kt @@ -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>(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.*()? + // (\w|())* + + // Then either the doctype gives the hint possibly surrounded by comments and/or whitespaces + // ))* + + // Or the root tag is svg, possibly preceding comments and/or whitespaces + // imageInputStream.skipUntil('?', '>') + //buffer[0] == '?'.code.toByte() -> imageInputStream.skipUntil('?', '>') + + // ` + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `can read svg with DOCTYPE after xml header`() { + val svg = """ + + + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `can read svg with comment before DOCTYPE`() { + val svg = """ + + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `can read svg starting with DOCTYPE`() { + val svg = """ + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `can read svg starting with DOCTYPE having spaces`() { + val svg = """ + + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `can read svg starting with SVG root tag`() { + val 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 = """ + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertTrue(SvgImageReaderSpi().canDecodeInput(stream)) + } + } + + @Test + fun `cannot read svg with broken comment`() { + val svg = """ + + + """.trimIndent() + + MemoryCacheImageInputStream(svg.byteInputStream()).use { stream -> + assertFalse(SvgImageReaderSpi().canDecodeInput(stream)) + } + } +} \ No newline at end of file