[JEWEL-1267] Support sized images

Added support for parsing and rendering sized images in Jewel Markdown
when image dimensions are provided via HTML `width` and `height`
attributes or adjacent markdown image attribute blocks.
Supported values include unitless pixel sizes, explicit `px` sizes,
and percentage-based dimensions; invalid or negative sizes are ignored,
with proportional scaling used when only one dimension is specified.

Co-authored-by: Daniel Bertoldi <dsdanielbertoldi@gmail.com>

closes https://github.com/JetBrains/intellij-community/pull/3527

(cherry picked from commit 46d67dbb36e7a7a7a3c8f567b40b14f56b491157)


(cherry picked from commit dbfa5b61ff51891079131da24a4ddba562e9edf5)

IJ-MR-211495

GitOrigin-RevId: 81a8cd0689e85d1077114bd3af56a42a5fb7d83f
This commit is contained in:
Alexander Kuznetsov
2026-07-17 17:01:41 +00:00
committed by intellij-monorepo-bot
co-authored by Daniel Bertoldi
parent 8768cbb083
commit 82bc9bccf2
12 changed files with 1005 additions and 65 deletions
@@ -1,3 +1,16 @@
*:org.jetbrains.jewel.markdown.DimensionSize
*f:org.jetbrains.jewel.markdown.DimensionSize$Pixels
- org.jetbrains.jewel.markdown.DimensionSize
- bsf:box-impl(I):org.jetbrains.jewel.markdown.DimensionSize$Pixels
- s:constructor-impl(I):I
- equals(java.lang.Object):Z
- s:equals-impl(I,java.lang.Object):Z
- sf:equals-impl0(I,I):Z
- f:getValue():I
- hashCode():I
- s:hashCode-impl(I):I
- s:toString-impl(I):java.lang.String
- bf:unbox-impl():I
*:org.jetbrains.jewel.markdown.InlineMarkdown
*f:org.jetbrains.jewel.markdown.InlineMarkdown$Code
- org.jetbrains.jewel.markdown.InlineMarkdown
@@ -40,13 +53,19 @@
- org.jetbrains.jewel.markdown.InlineMarkdown
- org.jetbrains.jewel.markdown.WithInlineMarkdown
- sf:$stable:I
- <init>(java.lang.String,java.lang.String,java.lang.String,java.util.List):V
- <init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V
- b:<init>(java.lang.String,java.lang.String,java.lang.String,java.util.List):V
- <init>(java.lang.String,java.lang.String,java.lang.String,java.util.List,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize):V
- b:<init>(java.lang.String,java.lang.String,java.lang.String,java.util.List,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,I,kotlin.jvm.internal.DefaultConstructorMarker):V
- b:<init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V
- <init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[],org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize):V
- b:<init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[],org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,I,kotlin.jvm.internal.DefaultConstructorMarker):V
- equals(java.lang.Object):Z
- f:getAlt():java.lang.String
- f:getHeight():org.jetbrains.jewel.markdown.DimensionSize
- getInlineContent():java.util.List
- f:getSource():java.lang.String
- f:getTitle():java.lang.String
- f:getWidth():org.jetbrains.jewel.markdown.DimensionSize
- hashCode():I
*f:org.jetbrains.jewel.markdown.InlineMarkdown$Link
- org.jetbrains.jewel.markdown.InlineMarkdown
@@ -1,6 +1,15 @@
// Signature format: 4.0
package org.jetbrains.jewel.markdown {
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public sealed interface DimensionSize {
}
@SuppressCompatibility @kotlin.jvm.JvmInline @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static final value class DimensionSize.Pixels implements org.jetbrains.jewel.markdown.DimensionSize {
ctor @KotlinOnly public DimensionSize.Pixels(int value);
method public int getValue();
property public int value;
}
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public sealed interface InlineMarkdown {
}
@@ -37,16 +46,20 @@ package org.jetbrains.jewel.markdown {
}
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi @org.jetbrains.jewel.foundation.GenerateDataFunctions public static final class InlineMarkdown.Image implements org.jetbrains.jewel.markdown.InlineMarkdown org.jetbrains.jewel.markdown.WithInlineMarkdown {
ctor public InlineMarkdown.Image(String source, String alt, String? title, java.util.List<? extends org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent);
ctor public InlineMarkdown.Image(String source, String alt, String? title, org.jetbrains.jewel.markdown.InlineMarkdown... inlineContent);
ctor public InlineMarkdown.Image(String source, String alt, String? title, java.util.List<? extends org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent, optional org.jetbrains.jewel.markdown.DimensionSize? width, optional org.jetbrains.jewel.markdown.DimensionSize? height);
ctor public InlineMarkdown.Image(String source, String alt, String? title, org.jetbrains.jewel.markdown.InlineMarkdown[] inlineContent, optional org.jetbrains.jewel.markdown.DimensionSize? width, optional org.jetbrains.jewel.markdown.DimensionSize? height);
method public String getAlt();
method public org.jetbrains.jewel.markdown.DimensionSize? getHeight();
method public java.util.List<org.jetbrains.jewel.markdown.InlineMarkdown> getInlineContent();
method public String getSource();
method public String? getTitle();
method public org.jetbrains.jewel.markdown.DimensionSize? getWidth();
property public String alt;
property public org.jetbrains.jewel.markdown.DimensionSize? height;
property public java.util.List<org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent;
property public String source;
property public String? title;
property public org.jetbrains.jewel.markdown.DimensionSize? width;
}
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi @org.jetbrains.jewel.foundation.GenerateDataFunctions public static final class InlineMarkdown.Link implements org.jetbrains.jewel.markdown.InlineMarkdown org.jetbrains.jewel.markdown.WithInlineMarkdown {
@@ -0,0 +1,43 @@
package org.jetbrains.jewel.markdown
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
/**
* Represents a size value for an image dimension (width or height).
*
* Currently only pixel values ([Pixels]) are supported.
*/
@ApiStatus.Experimental
@ExperimentalJewelApi
public sealed interface DimensionSize {
/** A loaded image should be exactly [value] pixels in the specified dimension. */
@ApiStatus.Experimental
@ExperimentalJewelApi
@JvmInline
public value class Pixels(public val value: Int) : DimensionSize {
init {
require(value >= 0) { "Value cannot be negative." }
}
override fun toString(): String = "${value}px"
}
// TODO[JEWEL-1333]: Explore percentage sizing further since the first iteration resized the image in relation
// to the percentage instead of making the image fill the required percentage of the available Canvas space.
}
internal fun String.parseDimensionSize(): DimensionSize? {
val trimmed = trim()
if (trimmed.isEmpty()) return null
val number = trimmed.takeWhile { it.isDigit() }
val convertedNumber = number.toIntOrNull() ?: return null
val normalizedUnit = trimmed.substringAfter(number).trim().trimEnd(';').lowercase()
return when (normalizedUnit) {
"" -> DimensionSize.Pixels(convertedNumber)
"px" -> DimensionSize.Pixels(convertedNumber)
else -> null
}
}
@@ -104,6 +104,22 @@ public sealed interface InlineMarkdown {
override fun toString(): String = "HtmlInline(content='$content')"
}
/**
* An inline image node, corresponding to `![alt](source "title")` in Markdown.
*
* Standard renderer implementations apply the following sizing rules based on [width] and [height]:
* - If you specify both sizes, the image is rendered at exactly those dimensions, stretching if the aspect ratio
* differs from the original.
* - If you specify only one of them, the other dimension is scaled proportionally to preserve the aspect ratio.
* - If you don't specify either, the image is rendered at its intrinsic (loaded) size.
*
* @param source The URL or path of the image.
* @param alt The plain-text alternative description of the image.
* @param title The optional tooltip title of the image.
* @param inlineContent The parsed inline nodes that make up the alt text.
* @param width The optional display width. See [DimensionSize] for supported value types.
* @param height The optional display height. See [DimensionSize] for supported value types.
*/
@ApiStatus.Experimental
@ExperimentalJewelApi
@GenerateDataFunctions
@@ -112,13 +128,33 @@ public sealed interface InlineMarkdown {
public val alt: String,
public val title: String?,
override val inlineContent: List<InlineMarkdown>,
public val width: DimensionSize? = null,
public val height: DimensionSize? = null,
) : InlineMarkdown, WithInlineMarkdown {
public constructor(
source: String,
alt: String,
title: String?,
vararg inlineContent: InlineMarkdown,
) : this(source, alt, title, inlineContent.toList())
width: DimensionSize? = null,
height: DimensionSize? = null,
) : this(source, alt, title, inlineContent.toList(), width, height)
@Deprecated("Use a constructor with width and height parameters instead.", level = DeprecationLevel.HIDDEN)
public constructor(
source: String,
alt: String,
title: String?,
vararg inlineContent: InlineMarkdown,
) : this(source, alt, title, inlineContent.toList(), null, null)
@Deprecated("Use a constructor with width and height parameters instead.", level = DeprecationLevel.HIDDEN)
public constructor(
source: String,
alt: String,
title: String?,
inlineContent: List<InlineMarkdown>,
) : this(source, alt, title, inlineContent, null, null)
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -130,6 +166,8 @@ public sealed interface InlineMarkdown {
if (alt != other.alt) return false
if (title != other.title) return false
if (inlineContent != other.inlineContent) return false
if (width != other.width) return false
if (height != other.height) return false
return true
}
@@ -139,6 +177,8 @@ public sealed interface InlineMarkdown {
result = 31 * result + alt.hashCode()
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + inlineContent.hashCode()
result = 31 * result + (width?.hashCode() ?: 0)
result = 31 * result + (height?.hashCode() ?: 0)
return result
}
@@ -147,7 +187,9 @@ public sealed interface InlineMarkdown {
"source='$source', " +
"alt='$alt', " +
"title=$title, " +
"inlineContent=$inlineContent" +
"inlineContent=$inlineContent, " +
"width=$width, " +
"height=$height" +
")"
}
}
@@ -1,5 +1,8 @@
package org.jetbrains.jewel.markdown.processing
import kotlin.text.first
import kotlin.text.indexOf
import kotlin.text.last
import org.commonmark.node.Code as CMCode
import org.commonmark.node.Delimited
import org.commonmark.node.Emphasis as CMEmphasis
@@ -15,9 +18,11 @@ import org.commonmark.parser.beta.ParsedInline
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.util.JewelLogger
import org.jetbrains.jewel.markdown.DimensionSize
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.WithInlineMarkdown
import org.jetbrains.jewel.markdown.WithTextContent
import org.jetbrains.jewel.markdown.parseDimensionSize
/**
* Reads all supported child inline nodes into a list of [InlineMarkdown] nodes, using the provided [markdownProcessor]
@@ -25,7 +30,7 @@ import org.jetbrains.jewel.markdown.WithTextContent
*
* @param markdownProcessor Used to parse the inline contents as needed.
* @return A list of the contents as parsed [InlineMarkdown].
* @see toInlineMarkdownOrNull
* @see convertToInlineMarkdown
*/
@ApiStatus.Experimental
@ExperimentalJewelApi
@@ -33,10 +38,20 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List<I
val inlines = buildList {
var current = this@readInlineMarkdown.firstChild
while (current != null) {
val inline = current.toInlineMarkdownOrNull(markdownProcessor)
val (inline, next) = current.convertToInlineMarkdown(markdownProcessor)
if (inline is InlineMarkdown.Image && next is CMText) {
val result = parseImageWithAttributes(inline, next)
if (result != null) {
val (imageWithAttrs, remainder) = result
add(imageWithAttrs)
if (remainder != null) add(InlineMarkdown.Text(remainder))
current = next.next
continue
}
}
if (inline != null) add(inline)
current = current.next
current = next
}
}
return markdownProcessor.convertHtmlInlines(inlines)
@@ -51,46 +66,85 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List<I
* registered to [markdownProcessor].
* @see readInlineMarkdown
*/
@Suppress("unused") // public API, might be used externally
@ExperimentalJewelApi
@ApiStatus.Experimental
public fun Node.toInlineMarkdownOrNull(markdownProcessor: MarkdownProcessor): InlineMarkdown? =
when (this) {
is CMText -> InlineMarkdown.Text(literal)
is CMLink ->
InlineMarkdown.Link(
destination = destination,
title = title,
inlineContent = readInlineMarkdown(markdownProcessor),
)
convertToInlineMarkdown(markdownProcessor).first
is CMEmphasis ->
InlineMarkdown.Emphasis(delimiter = openingDelimiter, inlineContent = readInlineMarkdown(markdownProcessor))
private fun Node.convertToInlineMarkdown(markdownProcessor: MarkdownProcessor): Pair<InlineMarkdown?, Node?> {
val next: Node? = this.next
val inlineContent =
when (this) {
is CMText -> InlineMarkdown.Text(literal)
is CMLink ->
InlineMarkdown.Link(
destination = destination,
title = title,
inlineContent = readInlineMarkdown(markdownProcessor),
)
is CMStrongEmphasis -> InlineMarkdown.StrongEmphasis(openingDelimiter, readInlineMarkdown(markdownProcessor))
is CMEmphasis ->
InlineMarkdown.Emphasis(
delimiter = openingDelimiter,
inlineContent = readInlineMarkdown(markdownProcessor),
)
is CMCode -> InlineMarkdown.Code(literal)
is CMHtmlInline -> InlineMarkdown.HtmlInline(literal)
is CMImage -> {
val inlineContent = readInlineMarkdown(markdownProcessor)
InlineMarkdown.Image(
source = destination,
alt = inlineContent.renderAsSimpleText().trim(),
title = title,
inlineContent = inlineContent,
)
is CMStrongEmphasis ->
InlineMarkdown.StrongEmphasis(openingDelimiter, readInlineMarkdown(markdownProcessor))
is CMCode -> InlineMarkdown.Code(literal)
is CMHtmlInline -> InlineMarkdown.HtmlInline(literal)
is CMImage -> {
val inlineContent = readInlineMarkdown(markdownProcessor)
InlineMarkdown.Image(
source = destination,
alt = inlineContent.renderAsSimpleText().trim(),
title = title,
inlineContent = inlineContent,
)
}
is CMHardLineBreak -> InlineMarkdown.HardLineBreak
is CMSoftLineBreak -> InlineMarkdown.SoftLineBreak
is Delimited ->
markdownProcessor.delimitedInlineExtensions
.find { it.canProcess(this) }
?.processDelimitedInline(this, markdownProcessor)
is ParsedInline -> null // Unsupported — see JEWEL-747
else -> error("Unexpected block $this")
}
return inlineContent to next
}
is CMHardLineBreak -> InlineMarkdown.HardLineBreak
is CMSoftLineBreak -> InlineMarkdown.SoftLineBreak
is Delimited ->
markdownProcessor.delimitedInlineExtensions
.find { it.canProcess(this) }
?.processDelimitedInline(this, markdownProcessor)
is ParsedInline -> null // Unsupported — see JEWEL-747
private fun getImageSize(attrs: String): Pair<DimensionSize?, DimensionSize?> =
if (attrs.isValidImageAttributes()) parseImageAttributes(attrs) else (null to null)
else -> error("Unexpected block $this")
private fun String.isValidImageAttributes(): Boolean =
length >= 2 && first() == '{' && last() == '}' && indexOf('\n') < 0 && indexOf('\r') < 0
private fun parseImageAttributes(attrs: String): Pair<DimensionSize?, DimensionSize?> {
val content = attrs.substring(1, attrs.lastIndex)
var width: DimensionSize? = null
var height: DimensionSize? = null
imageSizeAttributeRegex.findAll(content).forEach { match ->
val name = match.groupValues[1]
val value = match.groups[2]?.value ?: match.groups[3]?.value ?: match.groups[4]?.value.orEmpty()
when (name) {
"width" -> width = value.parseDimensionSize()
"height" -> height = value.parseDimensionSize()
}
}
return width to height
}
private val imageSizeAttributeRegex = Regex("""(?:^|\s)(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))""")
/** Used to render content as simple plain text, used when creating image alt text. */
internal fun List<InlineMarkdown>.renderAsSimpleText(): String = buildString {
for (node in this@renderAsSimpleText) {
@@ -106,3 +160,23 @@ internal fun List<InlineMarkdown>.renderAsSimpleText(): String = buildString {
}
}
}
private fun parseImageWithAttributes(image: InlineMarkdown.Image, next: CMText): Pair<InlineMarkdown.Image, String?>? {
val textLiteral = next.literal
val endAttrIdx = textLiteral.indexOf("}")
if (!textLiteral.startsWith("{") || endAttrIdx == -1) return null
val (width, height) = getImageSize(textLiteral.take(endAttrIdx + 1).trim())
if (width == null && height == null) return null // Cases like { random text } should be ignored
val remainder = textLiteral.substring(endAttrIdx + 1)
return InlineMarkdown.Image(
source = image.source,
alt = image.alt,
title = image.title,
inlineContent = image.inlineContent,
width = width,
height = height,
) to remainder.takeUnless { it.isBlank() }
}
@@ -3,6 +3,7 @@ package org.jetbrains.jewel.markdown.processing.html
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.MarkdownBlock
import org.jetbrains.jewel.markdown.parseDimensionSize
private typealias Tag = String
@@ -39,6 +40,9 @@ private object ImageConverter : HtmlElementConverter {
source = htmlElement.attributes["src"].orEmpty(),
alt = htmlElement.attributes["alt"].orEmpty(),
title = htmlElement.attributes["title"],
width = htmlElement.attributes["width"]?.parseDimensionSize(),
height = htmlElement.attributes["height"]?.parseDimensionSize(),
inlineContent = emptyList(),
)
)
)
@@ -3,6 +3,7 @@ package org.jetbrains.jewel.markdown.processing.html
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.WithTextContent
import org.jetbrains.jewel.markdown.parseDimensionSize
import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
internal class MarkdownHtmlInlinesConverter {
@@ -38,6 +39,9 @@ internal class MarkdownHtmlInlinesConverter {
source = element.attr("src"),
title = element.attr("title").ifEmpty { null },
alt = element.attr("alt"),
width = element.attr("width").parseDimensionSize(),
height = element.attr("height").parseDimensionSize(),
inlineContent = emptyList(),
)
)
}
@@ -0,0 +1,182 @@
package org.jetbrains.jewel.markdown
import org.jetbrains.jewel.markdown.InlineMarkdown.Image
import org.jetbrains.jewel.markdown.InlineMarkdown.Text
import org.jetbrains.jewel.markdown.MarkdownBlock.Paragraph
import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
import org.junit.Test
@Suppress("MarkdownUnresolvedFileReference")
public class MarkdownProcessorImageAttributesTest {
private val processor = MarkdownProcessor()
@Test
public fun `parses image with numeric width and height`() {
val parsed = processor.processMarkdownDocument("""![foo](image.jpg){width=100 height=50}""")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = DimensionSize.Pixels(100),
height = DimensionSize.Pixels(50),
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `parses image with only width specified`() {
val parsed = processor.processMarkdownDocument("""![foo](image.jpg){width=200}""")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = DimensionSize.Pixels(200),
height = null,
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `parses image with only height specified`() {
val parsed = processor.processMarkdownDocument("""![foo](image.jpg){height=150}""")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = null,
height = DimensionSize.Pixels(150),
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `parses image with unfinished attributes`() {
val parsed = processor.processMarkdownDocument("""![foo](image.jpg){width=100px he}""")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = DimensionSize.Pixels(100),
height = null,
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `ignores invalid size values inside a valid image attribute block`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg){width=??? height=200px}")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = null,
height = DimensionSize.Pixels(200),
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `ignores negative width inside a valid image attribute block`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg){width=-100% height=200px}")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = null,
height = DimensionSize.Pixels(200),
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `ignores negative height inside a valid image attribute block`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg){width=200 height=-100}")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = DimensionSize.Pixels(200),
height = null,
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test
public fun `does not treat spaced image attributes as image attributes`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg) {width=100}")
parsed.assertEquals(
Paragraph(
Image(source = "image.jpg", alt = "foo", title = null, inlineContent = listOf(Text("foo"))),
Text(" {width=100}"),
)
)
}
@Test
public fun `does not consume attribute block when no valid attributes are found`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg){wdth=50%} test")
parsed.assertEquals(
Paragraph(
Image(source = "image.jpg", alt = "foo", title = null, inlineContent = listOf(Text("foo"))),
Text("{wdth=50%} test"),
)
)
}
@Test
public fun `parses image with px unit followed by semicolon`() {
val parsed = processor.processMarkdownDocument("![foo](image.jpg){width=100px;}")
parsed.assertEquals(
Paragraph(
Image(
source = "image.jpg",
alt = "foo",
title = null,
width = DimensionSize.Pixels(100),
inlineContent = listOf(Text("foo")),
)
)
)
}
@Test(expected = IllegalArgumentException::class)
public fun `DimensionSize Pixel negative value should throw`() {
DimensionSize.Pixels(-1)
}
}
@@ -0,0 +1,73 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.jewel.markdown
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import org.junit.Test
public class ParseHtmlSizeValueTest {
@Test
public fun `parses bare integer as pixels`() {
assertEquals(DimensionSize.Pixels(100), "100".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses integer with semicolon as pixels`() {
assertEquals(DimensionSize.Pixels(100), "100;".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px as pixels`() {
assertEquals(DimensionSize.Pixels(100), "100px".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px with semicolon as pixels`() {
assertEquals(DimensionSize.Pixels(100), "100px;".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px uppercase`() {
assertEquals(DimensionSize.Pixels(100), "100PX".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px mixed case`() {
assertEquals(DimensionSize.Pixels(100), "100Px".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px with space between number and unit`() {
assertEquals(DimensionSize.Pixels(100), "100 px".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `parses px uppercase with space and semicolon`() {
assertEquals(DimensionSize.Pixels(100), "100 PX;".parseDimensionSize() as DimensionSize.Pixels)
}
@Test
public fun `rejects percent`() {
assertNull("50%".parseDimensionSize())
}
@Test
public fun `rejects unknown unit`() {
assertNull("100em".parseDimensionSize())
}
@Test
public fun `rejects garbage after px`() {
assertNull("50pxfoo".parseDimensionSize())
}
@Test
public fun `rejects empty string`() {
assertNull("".parseDimensionSize())
}
@Test
public fun `rejects non-numeric input`() {
assertNull("abc".parseDimensionSize())
}
}
@@ -1,12 +1,14 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.jewel.markdown.processing.html
import org.jetbrains.jewel.markdown.DimensionSize
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.MarkdownBlock
import org.jetbrains.jewel.markdown.assertEquals
import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
import org.junit.Test
@Suppress("LargeClass")
public class MarkdownHtmlConverterTest {
private val processor = MarkdownProcessor(parseEmbeddedHtml = true)
@@ -234,14 +236,21 @@ public class MarkdownHtmlConverterTest {
public fun `parses images -- img`() {
val parsed =
processor.processMarkdownDocument(
"Look at <img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20%\"/>!"
"Look at <img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20\"/>!"
)
parsed.assertEquals(
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Text("Look at "),
InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null),
InlineMarkdown.Image(
source = "art/jewel-logo.svg",
alt = "Jewel logo",
title = null,
width = DimensionSize.Pixels(20),
height = null,
inlineContent = emptyList(),
),
InlineMarkdown.Text("!"),
)
)
@@ -251,14 +260,23 @@ public class MarkdownHtmlConverterTest {
@Test
public fun `parses single image in a paragraph`() {
val parsed =
processor.processMarkdownDocument("<img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20%\"/>")
processor.processMarkdownDocument("<img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20\"/>")
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("width" to "20%", "src" to "art/jewel-logo.svg", "alt" to "Jewel logo"),
attributes = mapOf("width" to "20", "src" to "art/jewel-logo.svg", "alt" to "Jewel logo"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null))
listOf(
InlineMarkdown.Image(
source = "art/jewel-logo.svg",
alt = "Jewel logo",
title = null,
width = DimensionSize.Pixels(20),
height = null,
inlineContent = emptyList(),
)
)
),
)
)
@@ -298,7 +316,7 @@ public class MarkdownHtmlConverterTest {
val parsed =
processor.processMarkdownDocument(
"""
Please press <a href="https://example.com"><img alt="Jewel logo" src="art/jewel-logo.svg" width="20%"/></a>
Please press <a href="https://example.com"><img alt="Jewel logo" src="art/jewel-logo.svg" width="20"/></a>
"""
.trimIndent()
)
@@ -310,7 +328,14 @@ public class MarkdownHtmlConverterTest {
InlineMarkdown.Link(
destination = "https://example.com",
title = null,
InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null),
InlineMarkdown.Image(
source = "art/jewel-logo.svg",
alt = "Jewel logo",
title = null,
width = DimensionSize.Pixels(20),
height = null,
inlineContent = emptyList(),
),
),
)
)
@@ -560,4 +585,125 @@ public class MarkdownHtmlConverterTest {
)
)
}
@Test
public fun `parses img with numeric width and height`() {
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="100" height="50">""")
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("src" to "image.png", "width" to "100", "height" to "50"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Image(
source = "image.png",
alt = "",
title = null,
width = DimensionSize.Pixels(100),
height = DimensionSize.Pixels(50),
inlineContent = emptyList(),
)
)
),
)
)
}
@Test
public fun `parses img with extra suffix in size attributes`() {
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="100px;" height="50px;">""")
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("src" to "image.png", "width" to "100px;", "height" to "50px;"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Image(
source = "image.png",
alt = "",
title = null,
width = DimensionSize.Pixels(100),
height = DimensionSize.Pixels(50),
inlineContent = emptyList(),
)
)
),
)
)
}
@Test
public fun `parses img with only width specified`() {
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="200">""")
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("src" to "image.png", "width" to "200"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Image(
source = "image.png",
alt = "",
title = null,
width = DimensionSize.Pixels(200),
height = null,
inlineContent = emptyList(),
)
)
),
)
)
}
@Test
public fun `parses img with only height specified`() {
val parsed = processor.processMarkdownDocument("""<img src="image.png" height="150">""")
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("src" to "image.png", "height" to "150"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Image(
source = "image.png",
alt = "",
title = null,
width = null,
height = DimensionSize.Pixels(150),
inlineContent = emptyList(),
)
)
),
)
)
}
@Test
public fun `parses img with invalid width -- returns null for width`() {
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="auto">""")
// Invalid values like "auto" should not be parsed
parsed.assertEquals(
MarkdownBlock.HtmlBlockWithAttributes(
attributes = mapOf("src" to "image.png", "width" to "auto"),
mdBlock =
MarkdownBlock.Paragraph(
listOf(
InlineMarkdown.Image(
source = "image.png",
alt = "",
title = null,
width = null,
height = null,
inlineContent = emptyList(),
)
)
),
)
)
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -20,6 +21,7 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Size
import org.jetbrains.jewel.foundation.util.JewelLogger
import org.jetbrains.jewel.markdown.DimensionSize
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.extensions.ImageRendererExtension
import org.jetbrains.jewel.markdown.rendering.LocalMarkdownImageSourceResolver
@@ -41,7 +43,11 @@ internal class Coil3ImageRendererExtensionImpl(private val imageLoader: ImageLoa
* placeholder is initially small and is resized upon successful image loading to match the image's dimensions. The
* actual image is then rendered inside this placeholder.
*
* @param image The [InlineMarkdown.Image] data object containing the source, alt text, and title.
* If the image has a specified width and/or height, those dimensions are used for the placeholder. When only one
* dimension is specified, the other is scaled proportionally based on the loaded image's aspect ratio.
*
* @param image The [InlineMarkdown.Image] data object containing the source, alt text, title, and optional
* dimensions.
* @return An [InlineTextContent] that can be used by a `Text` or `BasicText` composable to render the image inline.
*/
@Composable
@@ -80,25 +86,95 @@ internal class Coil3ImageRendererExtensionImpl(private val imageLoader: ImageLoa
}
val placeholder =
imageResult?.let {
val imageSize = it.image
with(LocalDensity.current) {
// `toSp` ensures that the placeholder size matches the original image size in pixels.
// This approach doesn't allow images from appearing larger with different screen scaling,
// but simply maintains behavior consistent with standalone AsyncImage rendering.
Placeholder(
width = imageSize.width.toSp(),
height = imageSize.height.toSp(),
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
)
}
}
?: run {
Placeholder(width = 0.sp, height = 1.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom)
}
computePlaceholder(imageResult = imageResult, specifiedWidth = image.width, specifiedHeight = image.height)
return InlineTextContent(placeholder) {
Image(painter = painter, contentDescription = image.title, modifier = Modifier.fillMaxSize())
Image(
painter = painter,
contentDescription = image.title,
modifier = Modifier.fillMaxSize(),
contentScale =
if (image.width != null && image.height != null) {
ContentScale.FillBounds
} else {
ContentScale.Fit
},
)
}
}
/**
* Computes the placeholder size for the image.
*
* If both dimensions are specified, those are used. If only one dimension is specified, the other is scaled
* proportionally based on the loaded image's aspect ratio. If no dimensions are specified, the loaded image's
* original dimensions are used. While loading, a minimal placeholder is used unless dimensions are specified.
*/
@Composable
private fun computePlaceholder(
imageResult: SuccessResult?,
specifiedWidth: DimensionSize?,
specifiedHeight: DimensionSize?,
): Placeholder {
val density = LocalDensity.current
// At least one dimension is unspecified or requires the image to compute
if (imageResult == null) {
// Known pixel dimensions can be reserved while loading; unspecified dimensions fall back to the minimal
// placeholder.
val pixelWidth = specifiedWidth?.toPixels()
val pixelHeight = specifiedHeight?.toPixels()
return with(density) {
Placeholder(
width = pixelWidth?.toSp() ?: 1.sp,
height = pixelHeight?.toSp() ?: 1.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
)
}
}
// If we have a result, compute the final dimensions
val loadedImage = imageResult.image
val loadedWidth = loadedImage.width
val loadedHeight = loadedImage.height
// Resolve the specified dimensions to pixel values
val resolvedWidth = specifiedWidth?.toPixels()
val resolvedHeight = specifiedHeight?.toPixels()
val (finalWidth, finalHeight) =
when {
resolvedWidth != null && resolvedHeight != null -> {
resolvedWidth to resolvedHeight
}
resolvedWidth != null -> {
// Scale height proportionally
val scaledHeight = (resolvedWidth.toFloat() / loadedWidth * loadedHeight).toInt()
resolvedWidth to scaledHeight
}
resolvedHeight != null -> {
// Scale width proportionally
val scaledWidth = (resolvedHeight.toFloat() / loadedHeight * loadedWidth).toInt()
scaledWidth to resolvedHeight
}
else -> {
// No dimensions specified, use original
loadedWidth to loadedHeight
}
}
return with(density) {
Placeholder(
width = finalWidth.toSp(),
height = finalHeight.toSp(),
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
)
}
}
private fun DimensionSize.toPixels(): Int =
when (this) {
is DimensionSize.Pixels -> value
}
}
@@ -22,14 +22,17 @@ import coil3.decode.DataSource
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.test.FakeImageLoaderEngine
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import org.jetbrains.jewel.markdown.DimensionSize
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoilApi::class)
@Suppress("LargeClass")
public class Coil3ImageRendererExtensionImplTest {
@get:Rule public val composeTestRule: ComposeContentTestRule = createComposeRule()
@@ -92,7 +95,7 @@ public class Coil3ImageRendererExtensionImplTest {
.intercept(
predicate = { it == imageUrl },
interceptor = {
delay(500) // simulating network delay
delay(500.milliseconds) // simulating network delay
SuccessResult(fakeImage, it.request, DataSource.MEMORY)
},
@@ -110,7 +113,7 @@ public class Coil3ImageRendererExtensionImplTest {
composeTestRule
.onNodeWithContentDescription("A loading image")
.assertExists()
.assertWidthIsEqualTo(0.dp)
.assertWidthIsEqualTo(1.dp)
.assertHeightIsEqualTo(1.dp)
// fast forwarding coil's internal dispatcher
@@ -123,6 +126,267 @@ public class Coil3ImageRendererExtensionImplTest {
.assertHeightIsAtLeast(fakeImageHeight.dp)
}
@Test
public fun `image renders with specified pixel width and height`() {
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Blue.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify different dimensions than the actual image
val specifiedWidth = 100
val specifiedHeight = 50
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Sized image",
width = DimensionSize.Pixels(specifiedWidth),
height = DimensionSize.Pixels(specifiedHeight),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
composeTestRule
.onNodeWithContentDescription("Sized image")
.assertExists()
.assertWidthIsEqualTo(specifiedWidth.dp)
.assertHeightIsEqualTo(specifiedHeight.dp)
}
@Test
public fun `image with only pixel width specified scales height proportionally`() {
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Green.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify only width - height should be scaled proportionally
val specifiedWidth = 100
// Expected height: 100 / 200 * 100 = 50
val expectedHeight = 50
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Width only image",
width = DimensionSize.Pixels(specifiedWidth),
height = null,
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
composeTestRule
.onNodeWithContentDescription("Width only image")
.assertExists()
.assertWidthIsEqualTo(specifiedWidth.dp)
.assertHeightIsEqualTo(expectedHeight.dp)
}
@Test
public fun `image with only pixel height specified scales width proportionally`() {
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Yellow.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify only height - width should be scaled proportionally
val specifiedHeight = 50
// Expected width: 50 / 100 * 200 = 100
val expectedWidth = 100
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Height only image",
width = null,
height = DimensionSize.Pixels(specifiedHeight),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
composeTestRule
.onNodeWithContentDescription("Height only image")
.assertExists()
.assertWidthIsEqualTo(expectedWidth.dp)
.assertHeightIsEqualTo(specifiedHeight.dp)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
public fun `placeholder with specified pixel dimensions shows correct size during loading`() {
val testDispatcher = StandardTestDispatcher()
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Magenta.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine =
FakeImageLoaderEngine.Builder()
.intercept(
predicate = { it == imageUrl },
interceptor = {
delay(500) // simulating network delay
SuccessResult(fakeImage, it.request, DataSource.MEMORY)
},
)
.build()
val imageLoader =
ImageLoader.Builder(platformContext).components { add(engine) }.coroutineContext(testDispatcher).build()
val extension = Coil3ImageRendererExtensionImpl(imageLoader)
val specifiedWidth = 150
val specifiedHeight = 75
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Loading sized image",
width = DimensionSize.Pixels(specifiedWidth),
height = DimensionSize.Pixels(specifiedHeight),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
// During loading, placeholder should have the specified dimensions
composeTestRule
.onNodeWithContentDescription("Loading sized image")
.assertExists()
.assertWidthIsEqualTo(specifiedWidth.dp)
.assertHeightIsEqualTo(specifiedHeight.dp)
// Fast forward to complete loading
testDispatcher.scheduler.advanceTimeBy(501)
testDispatcher.scheduler.runCurrent()
// After loading, should still have the specified dimensions
composeTestRule
.onNodeWithContentDescription("Loading sized image")
.assertWidthIsEqualTo(specifiedWidth.dp)
.assertHeightIsEqualTo(specifiedHeight.dp)
}
@Test
public fun `image with both dimensions specified but different aspect ratio - stretched`() {
// Original image is 200x100 (2:1 aspect ratio)
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Green.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify 100x100 (1:1 aspect ratio) - image will be stretched/squished
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Stretched square image",
width = DimensionSize.Pixels(100),
height = DimensionSize.Pixels(100),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
// Both dimensions should be exactly as specified, even though aspect ratio differs
composeTestRule
.onNodeWithContentDescription("Stretched square image")
.assertExists()
.assertWidthIsEqualTo(100.dp)
.assertHeightIsEqualTo(100.dp)
}
@Test
public fun `image with both dimensions specified - taller than original aspect ratio`() {
// Original image is 200x100 (2:1 aspect ratio)
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Yellow.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify 50x200 (1:4 aspect ratio) - very different from original 2:1
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Tall stretched image",
width = DimensionSize.Pixels(50),
height = DimensionSize.Pixels(200),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
composeTestRule
.onNodeWithContentDescription("Tall stretched image")
.assertExists()
.assertWidthIsEqualTo(50.dp)
.assertHeightIsEqualTo(200.dp)
}
@Test
public fun `image with both dimensions specified - wider than original aspect ratio`() {
// Original image is 200x100 (2:1 aspect ratio)
val fakeImageWidth = 200
val fakeImageHeight = 100
val fakeImage = ColorImage(Color.Magenta.toArgb(), width = fakeImageWidth, height = fakeImageHeight)
val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build()
val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build()
val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine)
// Specify 300x50 (6:1 aspect ratio) - wider than original 2:1
val imageMarkdown =
InlineMarkdown.Image(
source = imageUrl,
alt = "Alt text",
title = "Wide stretched image",
width = DimensionSize.Pixels(300),
height = DimensionSize.Pixels(50),
inlineContent = emptyList(),
)
setContent(extension, imageMarkdown)
composeTestRule
.onNodeWithContentDescription("Wide stretched image")
.assertExists()
.assertWidthIsEqualTo(300.dp)
.assertHeightIsEqualTo(50.dp)
}
private fun setContent(extension: Coil3ImageRendererExtensionImpl, image: InlineMarkdown.Image) {
composeTestRule.setContent {
val inlineContent = buildMap { extension.renderImageContent(image)?.let { put("inlineTextContent", it) } }