diff --git a/platform/jewel/markdown/core/api-dump-experimental.txt b/platform/jewel/markdown/core/api-dump-experimental.txt index 7bab313a47f4f..ea1dfe9bd771c 100644 --- a/platform/jewel/markdown/core/api-dump-experimental.txt +++ b/platform/jewel/markdown/core/api-dump-experimental.txt @@ -1,3 +1,28 @@ +*:org.jetbrains.jewel.markdown.DimensionSize +*f:org.jetbrains.jewel.markdown.DimensionSize$Percent +- org.jetbrains.jewel.markdown.DimensionSize +- bsf:box-impl(I):org.jetbrains.jewel.markdown.DimensionSize$Percent +- 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 +*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 @@ -41,12 +66,16 @@ - 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.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,java.util.List):V +- b:(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,java.util.List,I,kotlin.jvm.internal.DefaultConstructorMarker):V - (java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):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/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 0000000000000..0c1250456126a --- /dev/null +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/DimensionSize.kt @@ -0,0 +1,21 @@ +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). Can be either a pixel value or a percentage. */ +@ApiStatus.Experimental +@ExperimentalJewelApi +public sealed interface DimensionSize { + /** A loaded image should be exactly [value] pixels in the specified dimension. */ + @JvmInline + public value class Pixels(public val value: Int) : DimensionSize { + override fun toString(): String = "${value}px" + } + + /** A loaded image should be [value]% of the specified dimension of the loaded image. */ + @JvmInline + public value class Percent(public val value: Int) : DimensionSize { + override fun toString(): String = "$value%" + } +} 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 8abe6879be9ca..209a583873fb5 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 @@ -111,6 +111,10 @@ public sealed interface InlineMarkdown { public val source: String, public val alt: String, public val title: String?, + /** The width of the image, or `null` if not specified. */ + public val width: DimensionSize? = null, + /** The height of the image, or `null` if not specified. */ + public val height: DimensionSize? = null, override val inlineContent: List, ) : InlineMarkdown, WithInlineMarkdown { public constructor( @@ -120,6 +124,13 @@ public sealed interface InlineMarkdown { vararg inlineContent: InlineMarkdown, ) : this(source, alt, title, inlineContent.toList()) + public constructor( + source: String, + alt: String, + title: String?, + inlineContent: List, + ) : this(source, alt, title, null, null, inlineContent) + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -130,6 +141,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 +152,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 +162,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 ae9dbea965a7a..408022570ef26 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 @@ -15,6 +15,7 @@ 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 @@ -33,10 +34,10 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List InlineMarkdown.Text(literal) - is CMLink -> - InlineMarkdown.Link( - destination = destination, - title = title, - inlineContent = readInlineMarkdown(markdownProcessor), - ) - - is CMEmphasis -> - InlineMarkdown.Emphasis(delimiter = openingDelimiter, inlineContent = readInlineMarkdown(markdownProcessor)) - - 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, - ) +public fun Node.toInlineMarkdownOrNull(markdownProcessor: MarkdownProcessor): Pair { + var 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 CMEmphasis -> + InlineMarkdown.Emphasis( + delimiter = openingDelimiter, + inlineContent = readInlineMarkdown(markdownProcessor), + ) + + is CMStrongEmphasis -> + InlineMarkdown.StrongEmphasis(openingDelimiter, readInlineMarkdown(markdownProcessor)) + + is CMCode -> InlineMarkdown.Code(literal) + is CMHtmlInline -> InlineMarkdown.HtmlInline(literal) + is CMImage -> { + val inlineContent = readInlineMarkdown(markdownProcessor) + val attrs = (next as? CMText)?.literal + if (attrs?.startsWith('{') == true) { + val (width, height) = getImageSize(attrs.trim()) + next = next.next + InlineMarkdown.Image( + source = destination, + alt = inlineContent.renderAsSimpleText().trim(), + title = title, + inlineContent = inlineContent, + width = width, + height = height, + ) + } else { + 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 +} + +private fun getImageSize(attrs: String): Pair = + if (attrs.isValidImageAttributes()) parseImageAttributes(attrs) else (null to null) + +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 - 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 + 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() - else -> error("Unexpected block $this") + when (name) { + "width" -> width = value.parseMarkdownImageSize() + "height" -> height = value.parseMarkdownImageSize() + } + } + + return width to height +} + +private fun String.parseMarkdownImageSize(): DimensionSize? { + val trimmed = trim() + if (trimmed.isEmpty()) return null + + return when { + trimmed.endsWith("px") -> trimmed.dropLast(2).toStrictIntOrNull()?.let(DimensionSize::Pixels) + trimmed.endsWith("%") -> trimmed.dropLast(1).toStrictIntOrNull()?.let(DimensionSize::Percent) + else -> trimmed.toStrictIntOrNull()?.let(DimensionSize::Pixels) } +} + +private fun String.toStrictIntOrNull(): Int? { + if (isEmpty() || any { !it.isDigit() }) return null + return toIntOrNull() +} + +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 { 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 0ccaa15a53159..92ec2fc9ecbc5 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 @@ -1,6 +1,7 @@ // 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 @@ -39,11 +40,32 @@ private object ImageConverter : HtmlElementConverter { source = htmlElement.attributes["src"].orEmpty(), alt = htmlElement.attributes["alt"].orEmpty(), title = htmlElement.attributes["title"], + width = htmlElement.attributes["width"]?.parseHtmlSizeValue(), + height = htmlElement.attributes["height"]?.parseHtmlSizeValue(), + inlineContent = emptyList(), ) ) ) } +internal fun String.parseHtmlSizeValue(): DimensionSize? { + val trimmed = trim() + if (trimmed.isEmpty()) return null + + if (trimmed.endsWith("%")) { + val digits = trimmed.dropLast(1).trim() + val value = digits.toIntOrNull() ?: return null + if (value < 0) return null + return DimensionSize.Percent(value) + } + + val digits = trimmed.takeWhile { it.isDigit() } + if (digits.isEmpty()) return null + + val value = digits.toIntOrNull() ?: return null + return DimensionSize.Pixels(value) +} + private object ParagraphConverter : HtmlElementConverter { override fun convert( htmlElement: MarkdownHtmlNode.Element, 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 f3023be8802da..162100a4ce689 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 @@ -38,6 +38,9 @@ internal class MarkdownHtmlInlinesConverter { source = element.attr("src"), title = element.attr("title").ifEmpty { null }, alt = element.attr("alt"), + width = element.attr("width").parseHtmlSizeValue(), + height = element.attr("height").parseHtmlSizeValue(), + 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 0000000000000..e42d368208ab9 --- /dev/null +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownProcessorImageAttributesTest.kt @@ -0,0 +1,230 @@ +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("FunctionName", "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 percentage width and height`() { + val parsed = processor.processMarkdownDocument("""![foo](image.jpg){height=75% width=50%}""") + + parsed.assertEquals( + Paragraph( + Image( + source = "image.jpg", + alt = "foo", + title = null, + width = DimensionSize.Percent(50), + height = DimensionSize.Percent(75), + inlineContent = listOf(Text("foo")), + ) + ) + ) + } + + @Test + public fun `parses image with quoted width and height`() { + val parsed = processor.processMarkdownDocument("""![foo](image.jpg){height="75%" width="50%"}""") + + parsed.assertEquals( + Paragraph( + Image( + source = "image.jpg", + alt = "foo", + title = null, + width = DimensionSize.Percent(50), + height = DimensionSize.Percent(75), + 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 mixed pixel and percentage dimensions`() { + val parsed = processor.processMarkdownDocument("""![foo](image.jpg){width=100px height=50%}""") + + parsed.assertEquals( + Paragraph( + Image( + source = "image.jpg", + alt = "foo", + title = null, + width = DimensionSize.Pixels(100), + height = DimensionSize.Percent(50), + 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 `parses first image attributes when another image follows in the same text node`() { + val parsed = processor.processMarkdownDocument("![](url){ width=50% }![](url2){ height=50% }") + + parsed.assertEquals( + Paragraph( + Image( + source = "url", + alt = "", + title = null, + width = DimensionSize.Percent(50), + height = null, + inlineContent = emptyList(), + ), + Image( + source = "url2", + alt = "", + title = null, + width = null, + height = DimensionSize.Percent(50), + inlineContent = emptyList(), + ), + ) + ) + } +} 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 8e92ecef51b73..3d43c2385d660 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) @@ -241,7 +243,14 @@ public class MarkdownHtmlConverterTest { 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.Percent(20), + height = null, + inlineContent = emptyList(), + ), InlineMarkdown.Text("!"), ) ) @@ -258,7 +267,16 @@ public class MarkdownHtmlConverterTest { 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.Percent(20), + height = null, + inlineContent = emptyList(), + ) + ) ), ) ) @@ -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.Percent(20), + height = null, + inlineContent = emptyList(), + ), ), ) ) @@ -560,4 +585,221 @@ 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 percentage width and height`() { + val parsed = processor.processMarkdownDocument("""""") + + parsed.assertEquals( + MarkdownBlock.HtmlBlockWithAttributes( + attributes = mapOf("src" to "image.png", "width" to "50%", "height" to "75%"), + mdBlock = + MarkdownBlock.Paragraph( + listOf( + InlineMarkdown.Image( + source = "image.png", + alt = "", + title = null, + width = DimensionSize.Percent(50), + height = DimensionSize.Percent(75), + 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 mixed pixel and percentage dimensions`() { + val parsed = processor.processMarkdownDocument("""""") + + parsed.assertEquals( + MarkdownBlock.HtmlBlockWithAttributes( + attributes = mapOf("src" to "image.png", "width" to "100px", "height" to "50%"), + mdBlock = + MarkdownBlock.Paragraph( + listOf( + InlineMarkdown.Image( + source = "image.png", + alt = "", + title = null, + width = DimensionSize.Pixels(100), + height = DimensionSize.Percent(50), + 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(), + ) + ) + ), + ) + ) + } + + @Test + public fun `parses img with negative percentage width -- returns null for width`() { + val parsed = processor.processMarkdownDocument("""""") + + parsed.assertEquals( + MarkdownBlock.HtmlBlockWithAttributes( + attributes = mapOf("src" to "image.png", "width" to "-20%", "height" to "75%"), + mdBlock = + MarkdownBlock.Paragraph( + listOf( + InlineMarkdown.Image( + source = "image.png", + alt = "", + title = null, + width = null, + height = DimensionSize.Percent(75), + inlineContent = emptyList(), + ) + ) + ), + ) + ) + } + + @Test + public fun `parses img with negative percentage height -- returns null for height`() { + val parsed = processor.processMarkdownDocument("""""") + + parsed.assertEquals( + MarkdownBlock.HtmlBlockWithAttributes( + attributes = mapOf("src" to "image.png", "width" to "50%", "height" to "-25%"), + mdBlock = + MarkdownBlock.Paragraph( + listOf( + InlineMarkdown.Image( + source = "image.png", + alt = "", + title = null, + width = DimensionSize.Percent(50), + 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 48fe76b031270..0ebe6c4e9bb34 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 @@ -20,6 +20,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 +42,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 +85,98 @@ 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. + computePlaceholder(imageResult = imageResult, specifiedWidth = image.width, specifiedHeight = image.height) + + return InlineTextContent(placeholder) { + Image(painter = painter, contentDescription = image.title, modifier = Modifier.fillMaxSize()) + } + } + + /** + * 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. + * + * For percentage values, they are treated as a percentage of the original image size. + */ + @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; return the "empty" placeholder + if (imageResult == null) { + // For pixel values, we can show them immediately; for percentage we need the image + val pixelWidth = (specifiedWidth as? DimensionSize.Pixels)?.value + val pixelHeight = (specifiedHeight as? DimensionSize.Pixels)?.value + + if (pixelWidth != null && pixelHeight != null) { + return with(density) { Placeholder( - width = imageSize.width.toSp(), - height = imageSize.height.toSp(), + width = pixelWidth.toSp(), + height = pixelHeight.toSp(), placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom, ) } } - ?: run { - Placeholder(width = 0.sp, height = 1.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom) + + // `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. + return Placeholder(width = 0.sp, height = 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(loadedWidth) + val resolvedHeight = specifiedHeight?.toPixels(loadedHeight) + + 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 InlineTextContent(placeholder) { - Image(painter = painter, contentDescription = image.title, modifier = Modifier.fillMaxSize()) + return with(density) { + Placeholder( + width = finalWidth.toSp(), + height = finalHeight.toSp(), + placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom, + ) } } + + /** + * Converts an [DimensionSize] to pixels. For [DimensionSize.Pixels], returns the value directly. For + * [DimensionSize.Percent], returns the percentage of the [originalDimension]. + */ + private fun DimensionSize.toPixels(originalDimension: Int): Int = + when (this) { + is DimensionSize.Pixels -> value + is DimensionSize.Percent -> (originalDimension * value / 100) + } } 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 d3a7e54671aaf..91ac0ccc750a0 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 @@ -25,11 +25,13 @@ import coil3.test.FakeImageLoaderEngine 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() @@ -123,6 +125,462 @@ 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 percentage width scales to percentage of original`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val fakeImage = ColorImage(Color.Cyan.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 50% width - should be 100px (50% of 200) + // Height should scale proportionally: 100 / 200 * 100 = 50 + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Percentage width image", + width = DimensionSize.Percent(50), + height = null, + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Percentage width image") + .assertExists() + .assertWidthIsEqualTo(100.dp) // 50% of 200 + .assertHeightIsEqualTo(50.dp) // scaled proportionally + } + + @Test + public fun `image with percentage height scales to percentage of original`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val fakeImage = ColorImage(Color.Gray.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 50% height - should be 50px (50% of 100) + // Width should scale proportionally: 50 / 100 * 200 = 100 + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Percentage height image", + width = null, + height = DimensionSize.Percent(50), + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Percentage height image") + .assertExists() + .assertWidthIsEqualTo(100.dp) // scaled proportionally + .assertHeightIsEqualTo(50.dp) // 50% of 100 + } + + @Test + public fun `image with both percentage dimensions`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val fakeImage = ColorImage(Color.LightGray.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 25% width and 50% height + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Both percentage image", + width = DimensionSize.Percent(25), + height = DimensionSize.Percent(50), + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Both percentage image") + .assertExists() + .assertWidthIsEqualTo(50.dp) // 25% of 200 + .assertHeightIsEqualTo(50.dp) // 50% of 100 + } + + @Test + public fun `image with pixel width and percentage height`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val fakeImage = ColorImage(Color.Red.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 150px width and 50% height (50px = 50% of 100) + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Mixed pixel width percentage height", + width = DimensionSize.Pixels(150), + height = DimensionSize.Percent(50), + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Mixed pixel width percentage height") + .assertExists() + .assertWidthIsEqualTo(150.dp) + .assertHeightIsEqualTo(50.dp) // 50% of 100 + } + + @Test + public fun `image with percentage width and pixel 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 75% width (150px = 75% of 200) and 80px height + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Mixed percentage width pixel height", + width = DimensionSize.Percent(75), + height = DimensionSize.Pixels(80), + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Mixed percentage width pixel height") + .assertExists() + .assertWidthIsEqualTo(150.dp) // 75% of 200 + .assertHeightIsEqualTo(80.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) + } + + @Test + public fun `image with both percentage dimensions - different aspect ratio`() { + // Original image is 200x100 (2:1 aspect ratio) + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val fakeImage = ColorImage(Color.Cyan.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 50% width (100px) and 100% height (100px) - becomes 1:1 aspect ratio + val imageMarkdown = + InlineMarkdown.Image( + source = imageUrl, + alt = "Alt text", + title = "Percentage different aspect", + width = DimensionSize.Percent(50), + height = DimensionSize.Percent(100), + inlineContent = emptyList(), + ) + + setContent(extension, imageMarkdown) + + composeTestRule + .onNodeWithContentDescription("Percentage different aspect") + .assertExists() + .assertWidthIsEqualTo(100.dp) // 50% of 200 + .assertHeightIsEqualTo(100.dp) // 100% of 100 + } + private fun setContent(extension: Coil3ImageRendererExtensionImpl, image: InlineMarkdown.Image) { composeTestRule.setContent { val inlineContent = buildMap { extension.renderImageContent(image)?.let { put("inlineTextContent", it) } }