From 82bc9bccf225ddc7cec9b561c0776906ffc1968f Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Fri, 27 Feb 2026 11:14:06 +0100 Subject: [PATCH] [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 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 --- .../markdown/core/api-dump-experimental.txt | 23 +- .../core/metalava/core-api-0.39.0.txt | 17 +- .../jetbrains/jewel/markdown/DimensionSize.kt | 43 +++ .../jewel/markdown/InlineMarkdown.kt | 46 ++- .../markdown/processing/ProcessingUtil.kt | 138 ++++++--- .../markdown/processing/html/Converters.kt | 4 + .../html/MarkdownHtmlInlinesConverter.kt | 4 + .../MarkdownProcessorImageAttributesTest.kt | 182 ++++++++++++ .../jewel/markdown/ParseHtmlSizeValueTest.kt | 73 +++++ .../html/MarkdownHtmlConverterTest.kt | 160 ++++++++++- .../images/Coil3ImageRendererExtensionImpl.kt | 112 ++++++-- .../Coil3ImageRendererExtensionImplTest.kt | 268 +++++++++++++++++- 12 files changed, 1005 insertions(+), 65 deletions(-) create mode 100644 platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/DimensionSize.kt create mode 100644 platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownProcessorImageAttributesTest.kt create mode 100644 platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/ParseHtmlSizeValueTest.kt diff --git a/platform/jewel/markdown/core/api-dump-experimental.txt b/platform/jewel/markdown/core/api-dump-experimental.txt index bc95960a3b83..372563123880 100644 --- a/platform/jewel/markdown/core/api-dump-experimental.txt +++ b/platform/jewel/markdown/core/api-dump-experimental.txt @@ -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 -- (java.lang.String,java.lang.String,java.lang.String,java.util.List):V -- (java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V +- b:(java.lang.String,java.lang.String,java.lang.String,java.util.List):V +- (java.lang.String,java.lang.String,java.lang.String,java.util.List,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize):V +- b:(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:(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V +- (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:(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 diff --git a/platform/jewel/markdown/core/metalava/core-api-0.39.0.txt b/platform/jewel/markdown/core/metalava/core-api-0.39.0.txt index 4e79f55f8647..f0b9850945c9 100644 --- a/platform/jewel/markdown/core/metalava/core-api-0.39.0.txt +++ b/platform/jewel/markdown/core/metalava/core-api-0.39.0.txt @@ -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 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 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 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 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 { diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/DimensionSize.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/DimensionSize.kt new file mode 100644 index 000000000000..8cc87795e8fa --- /dev/null +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/DimensionSize.kt @@ -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 + } +} diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/InlineMarkdown.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/InlineMarkdown.kt index 8abe6879be9c..ada7169550cd 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/InlineMarkdown.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/InlineMarkdown.kt @@ -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, + 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, + ) : 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" + ")" } } diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt index ae9dbea965a7..0c1014902b46 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt @@ -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 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 { + 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 = + 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 { + 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.renderAsSimpleText(): String = buildString { for (node in this@renderAsSimpleText) { @@ -106,3 +160,23 @@ internal fun List.renderAsSimpleText(): String = buildString { } } } + +private fun parseImageWithAttributes(image: InlineMarkdown.Image, next: CMText): Pair? { + 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() } +} diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/Converters.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/Converters.kt index 0ccaa15a5315..639075de3b23 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/Converters.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/Converters.kt @@ -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(), ) ) ) diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlInlinesConverter.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlInlinesConverter.kt index f3023be8802d..2718ad1f6128 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlInlinesConverter.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlInlinesConverter.kt @@ -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(), ) ) } diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownProcessorImageAttributesTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownProcessorImageAttributesTest.kt new file mode 100644 index 000000000000..55576fa7fb9c --- /dev/null +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownProcessorImageAttributesTest.kt @@ -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) + } +} diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/ParseHtmlSizeValueTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/ParseHtmlSizeValueTest.kt new file mode 100644 index 000000000000..d0a6f1bf8d09 --- /dev/null +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/ParseHtmlSizeValueTest.kt @@ -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()) + } +} diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlConverterTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlConverterTest.kt index 8e92ecef51b7..2b9f6668a47e 100644 --- a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlConverterTest.kt +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/html/MarkdownHtmlConverterTest.kt @@ -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 \"Jewel!" + "Look at \"Jewel!" ) 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("\"Jewel") + processor.processMarkdownDocument("\"Jewel") 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 Jewel logo + Please press Jewel logo """ .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("""""") + + 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("""""") + + 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("""""") + + 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("""""") + + 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("""""") + + // 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(), + ) + ) + ), + ) + ) + } } diff --git a/platform/jewel/markdown/extensions/images/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImpl.kt b/platform/jewel/markdown/extensions/images/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImpl.kt index 48fe76b03127..63d09f8bf1d5 100644 --- a/platform/jewel/markdown/extensions/images/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImpl.kt +++ b/platform/jewel/markdown/extensions/images/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImpl.kt @@ -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 + } } diff --git a/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt b/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt index d3a7e54671aa..2d3a4a7e1bc9 100644 --- a/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt +++ b/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt @@ -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) } }