mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[JEWEL-1267] Support sized images
Added support for parsing and rendering sized images in Jewel Markdown when image dimensions are provided via HTML `width` and `height` attributes or adjacent markdown image attribute blocks. Supported values include unitless pixel sizes, explicit `px` sizes, and percentage-based dimensions; invalid or negative sizes are ignored, with proportional scaling used when only one dimension is specified. Co-authored-by: Daniel Bertoldi <dsdanielbertoldi@gmail.com> closes https://github.com/JetBrains/intellij-community/pull/3527 (cherry picked from commit 46d67dbb36e7a7a7a3c8f567b40b14f56b491157) (cherry picked from commit dbfa5b61ff51891079131da24a4ddba562e9edf5) IJ-MR-211495 GitOrigin-RevId: 81a8cd0689e85d1077114bd3af56a42a5fb7d83f
This commit is contained in:
committed by
intellij-monorepo-bot
co-authored by
Daniel Bertoldi
parent
8768cbb083
commit
82bc9bccf2
@@ -1,3 +1,16 @@
|
||||
*:org.jetbrains.jewel.markdown.DimensionSize
|
||||
*f:org.jetbrains.jewel.markdown.DimensionSize$Pixels
|
||||
- org.jetbrains.jewel.markdown.DimensionSize
|
||||
- bsf:box-impl(I):org.jetbrains.jewel.markdown.DimensionSize$Pixels
|
||||
- s:constructor-impl(I):I
|
||||
- equals(java.lang.Object):Z
|
||||
- s:equals-impl(I,java.lang.Object):Z
|
||||
- sf:equals-impl0(I,I):Z
|
||||
- f:getValue():I
|
||||
- hashCode():I
|
||||
- s:hashCode-impl(I):I
|
||||
- s:toString-impl(I):java.lang.String
|
||||
- bf:unbox-impl():I
|
||||
*:org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
*f:org.jetbrains.jewel.markdown.InlineMarkdown$Code
|
||||
- org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
@@ -40,13 +53,19 @@
|
||||
- org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
- org.jetbrains.jewel.markdown.WithInlineMarkdown
|
||||
- sf:$stable:I
|
||||
- <init>(java.lang.String,java.lang.String,java.lang.String,java.util.List):V
|
||||
- <init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V
|
||||
- b:<init>(java.lang.String,java.lang.String,java.lang.String,java.util.List):V
|
||||
- <init>(java.lang.String,java.lang.String,java.lang.String,java.util.List,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize):V
|
||||
- b:<init>(java.lang.String,java.lang.String,java.lang.String,java.util.List,org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,I,kotlin.jvm.internal.DefaultConstructorMarker):V
|
||||
- b:<init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[]):V
|
||||
- <init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[],org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize):V
|
||||
- b:<init>(java.lang.String,java.lang.String,java.lang.String,org.jetbrains.jewel.markdown.InlineMarkdown[],org.jetbrains.jewel.markdown.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,I,kotlin.jvm.internal.DefaultConstructorMarker):V
|
||||
- equals(java.lang.Object):Z
|
||||
- f:getAlt():java.lang.String
|
||||
- f:getHeight():org.jetbrains.jewel.markdown.DimensionSize
|
||||
- getInlineContent():java.util.List
|
||||
- f:getSource():java.lang.String
|
||||
- f:getTitle():java.lang.String
|
||||
- f:getWidth():org.jetbrains.jewel.markdown.DimensionSize
|
||||
- hashCode():I
|
||||
*f:org.jetbrains.jewel.markdown.InlineMarkdown$Link
|
||||
- org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// Signature format: 4.0
|
||||
package org.jetbrains.jewel.markdown {
|
||||
|
||||
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public sealed interface DimensionSize {
|
||||
}
|
||||
|
||||
@SuppressCompatibility @kotlin.jvm.JvmInline @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static final value class DimensionSize.Pixels implements org.jetbrains.jewel.markdown.DimensionSize {
|
||||
ctor @KotlinOnly public DimensionSize.Pixels(int value);
|
||||
method public int getValue();
|
||||
property public int value;
|
||||
}
|
||||
|
||||
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public sealed interface InlineMarkdown {
|
||||
}
|
||||
|
||||
@@ -37,16 +46,20 @@ package org.jetbrains.jewel.markdown {
|
||||
}
|
||||
|
||||
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi @org.jetbrains.jewel.foundation.GenerateDataFunctions public static final class InlineMarkdown.Image implements org.jetbrains.jewel.markdown.InlineMarkdown org.jetbrains.jewel.markdown.WithInlineMarkdown {
|
||||
ctor public InlineMarkdown.Image(String source, String alt, String? title, java.util.List<? extends org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent);
|
||||
ctor public InlineMarkdown.Image(String source, String alt, String? title, org.jetbrains.jewel.markdown.InlineMarkdown... inlineContent);
|
||||
ctor public InlineMarkdown.Image(String source, String alt, String? title, java.util.List<? extends org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent, optional org.jetbrains.jewel.markdown.DimensionSize? width, optional org.jetbrains.jewel.markdown.DimensionSize? height);
|
||||
ctor public InlineMarkdown.Image(String source, String alt, String? title, org.jetbrains.jewel.markdown.InlineMarkdown[] inlineContent, optional org.jetbrains.jewel.markdown.DimensionSize? width, optional org.jetbrains.jewel.markdown.DimensionSize? height);
|
||||
method public String getAlt();
|
||||
method public org.jetbrains.jewel.markdown.DimensionSize? getHeight();
|
||||
method public java.util.List<org.jetbrains.jewel.markdown.InlineMarkdown> getInlineContent();
|
||||
method public String getSource();
|
||||
method public String? getTitle();
|
||||
method public org.jetbrains.jewel.markdown.DimensionSize? getWidth();
|
||||
property public String alt;
|
||||
property public org.jetbrains.jewel.markdown.DimensionSize? height;
|
||||
property public java.util.List<org.jetbrains.jewel.markdown.InlineMarkdown> inlineContent;
|
||||
property public String source;
|
||||
property public String? title;
|
||||
property public org.jetbrains.jewel.markdown.DimensionSize? width;
|
||||
}
|
||||
|
||||
@SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi @org.jetbrains.jewel.foundation.GenerateDataFunctions public static final class InlineMarkdown.Link implements org.jetbrains.jewel.markdown.InlineMarkdown org.jetbrains.jewel.markdown.WithInlineMarkdown {
|
||||
|
||||
+43
@@ -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
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -104,6 +104,22 @@ public sealed interface InlineMarkdown {
|
||||
override fun toString(): String = "HtmlInline(content='$content')"
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline image node, corresponding to `` in Markdown.
|
||||
*
|
||||
* Standard renderer implementations apply the following sizing rules based on [width] and [height]:
|
||||
* - If you specify both sizes, the image is rendered at exactly those dimensions, stretching if the aspect ratio
|
||||
* differs from the original.
|
||||
* - If you specify only one of them, the other dimension is scaled proportionally to preserve the aspect ratio.
|
||||
* - If you don't specify either, the image is rendered at its intrinsic (loaded) size.
|
||||
*
|
||||
* @param source The URL or path of the image.
|
||||
* @param alt The plain-text alternative description of the image.
|
||||
* @param title The optional tooltip title of the image.
|
||||
* @param inlineContent The parsed inline nodes that make up the alt text.
|
||||
* @param width The optional display width. See [DimensionSize] for supported value types.
|
||||
* @param height The optional display height. See [DimensionSize] for supported value types.
|
||||
*/
|
||||
@ApiStatus.Experimental
|
||||
@ExperimentalJewelApi
|
||||
@GenerateDataFunctions
|
||||
@@ -112,13 +128,33 @@ public sealed interface InlineMarkdown {
|
||||
public val alt: String,
|
||||
public val title: String?,
|
||||
override val inlineContent: List<InlineMarkdown>,
|
||||
public val width: DimensionSize? = null,
|
||||
public val height: DimensionSize? = null,
|
||||
) : InlineMarkdown, WithInlineMarkdown {
|
||||
public constructor(
|
||||
source: String,
|
||||
alt: String,
|
||||
title: String?,
|
||||
vararg inlineContent: InlineMarkdown,
|
||||
) : this(source, alt, title, inlineContent.toList())
|
||||
width: DimensionSize? = null,
|
||||
height: DimensionSize? = null,
|
||||
) : this(source, alt, title, inlineContent.toList(), width, height)
|
||||
|
||||
@Deprecated("Use a constructor with width and height parameters instead.", level = DeprecationLevel.HIDDEN)
|
||||
public constructor(
|
||||
source: String,
|
||||
alt: String,
|
||||
title: String?,
|
||||
vararg inlineContent: InlineMarkdown,
|
||||
) : this(source, alt, title, inlineContent.toList(), null, null)
|
||||
|
||||
@Deprecated("Use a constructor with width and height parameters instead.", level = DeprecationLevel.HIDDEN)
|
||||
public constructor(
|
||||
source: String,
|
||||
alt: String,
|
||||
title: String?,
|
||||
inlineContent: List<InlineMarkdown>,
|
||||
) : this(source, alt, title, inlineContent, null, null)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -130,6 +166,8 @@ public sealed interface InlineMarkdown {
|
||||
if (alt != other.alt) return false
|
||||
if (title != other.title) return false
|
||||
if (inlineContent != other.inlineContent) return false
|
||||
if (width != other.width) return false
|
||||
if (height != other.height) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -139,6 +177,8 @@ public sealed interface InlineMarkdown {
|
||||
result = 31 * result + alt.hashCode()
|
||||
result = 31 * result + (title?.hashCode() ?: 0)
|
||||
result = 31 * result + inlineContent.hashCode()
|
||||
result = 31 * result + (width?.hashCode() ?: 0)
|
||||
result = 31 * result + (height?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -147,7 +187,9 @@ public sealed interface InlineMarkdown {
|
||||
"source='$source', " +
|
||||
"alt='$alt', " +
|
||||
"title=$title, " +
|
||||
"inlineContent=$inlineContent" +
|
||||
"inlineContent=$inlineContent, " +
|
||||
"width=$width, " +
|
||||
"height=$height" +
|
||||
")"
|
||||
}
|
||||
}
|
||||
|
||||
+106
-32
@@ -1,5 +1,8 @@
|
||||
package org.jetbrains.jewel.markdown.processing
|
||||
|
||||
import kotlin.text.first
|
||||
import kotlin.text.indexOf
|
||||
import kotlin.text.last
|
||||
import org.commonmark.node.Code as CMCode
|
||||
import org.commonmark.node.Delimited
|
||||
import org.commonmark.node.Emphasis as CMEmphasis
|
||||
@@ -15,9 +18,11 @@ import org.commonmark.parser.beta.ParsedInline
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
|
||||
import org.jetbrains.jewel.foundation.util.JewelLogger
|
||||
import org.jetbrains.jewel.markdown.DimensionSize
|
||||
import org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
import org.jetbrains.jewel.markdown.WithInlineMarkdown
|
||||
import org.jetbrains.jewel.markdown.WithTextContent
|
||||
import org.jetbrains.jewel.markdown.parseDimensionSize
|
||||
|
||||
/**
|
||||
* Reads all supported child inline nodes into a list of [InlineMarkdown] nodes, using the provided [markdownProcessor]
|
||||
@@ -25,7 +30,7 @@ import org.jetbrains.jewel.markdown.WithTextContent
|
||||
*
|
||||
* @param markdownProcessor Used to parse the inline contents as needed.
|
||||
* @return A list of the contents as parsed [InlineMarkdown].
|
||||
* @see toInlineMarkdownOrNull
|
||||
* @see convertToInlineMarkdown
|
||||
*/
|
||||
@ApiStatus.Experimental
|
||||
@ExperimentalJewelApi
|
||||
@@ -33,10 +38,20 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List<I
|
||||
val inlines = buildList {
|
||||
var current = this@readInlineMarkdown.firstChild
|
||||
while (current != null) {
|
||||
val inline = current.toInlineMarkdownOrNull(markdownProcessor)
|
||||
val (inline, next) = current.convertToInlineMarkdown(markdownProcessor)
|
||||
if (inline is InlineMarkdown.Image && next is CMText) {
|
||||
val result = parseImageWithAttributes(inline, next)
|
||||
if (result != null) {
|
||||
val (imageWithAttrs, remainder) = result
|
||||
add(imageWithAttrs)
|
||||
if (remainder != null) add(InlineMarkdown.Text(remainder))
|
||||
current = next.next
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (inline != null) add(inline)
|
||||
|
||||
current = current.next
|
||||
current = next
|
||||
}
|
||||
}
|
||||
return markdownProcessor.convertHtmlInlines(inlines)
|
||||
@@ -51,46 +66,85 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List<I
|
||||
* registered to [markdownProcessor].
|
||||
* @see readInlineMarkdown
|
||||
*/
|
||||
@Suppress("unused") // public API, might be used externally
|
||||
@ExperimentalJewelApi
|
||||
@ApiStatus.Experimental
|
||||
public fun Node.toInlineMarkdownOrNull(markdownProcessor: MarkdownProcessor): InlineMarkdown? =
|
||||
when (this) {
|
||||
is CMText -> InlineMarkdown.Text(literal)
|
||||
is CMLink ->
|
||||
InlineMarkdown.Link(
|
||||
destination = destination,
|
||||
title = title,
|
||||
inlineContent = readInlineMarkdown(markdownProcessor),
|
||||
)
|
||||
convertToInlineMarkdown(markdownProcessor).first
|
||||
|
||||
is CMEmphasis ->
|
||||
InlineMarkdown.Emphasis(delimiter = openingDelimiter, inlineContent = readInlineMarkdown(markdownProcessor))
|
||||
private fun Node.convertToInlineMarkdown(markdownProcessor: MarkdownProcessor): Pair<InlineMarkdown?, Node?> {
|
||||
val next: Node? = this.next
|
||||
val inlineContent =
|
||||
when (this) {
|
||||
is CMText -> InlineMarkdown.Text(literal)
|
||||
is CMLink ->
|
||||
InlineMarkdown.Link(
|
||||
destination = destination,
|
||||
title = title,
|
||||
inlineContent = readInlineMarkdown(markdownProcessor),
|
||||
)
|
||||
|
||||
is CMStrongEmphasis -> InlineMarkdown.StrongEmphasis(openingDelimiter, readInlineMarkdown(markdownProcessor))
|
||||
is CMEmphasis ->
|
||||
InlineMarkdown.Emphasis(
|
||||
delimiter = openingDelimiter,
|
||||
inlineContent = readInlineMarkdown(markdownProcessor),
|
||||
)
|
||||
|
||||
is CMCode -> InlineMarkdown.Code(literal)
|
||||
is CMHtmlInline -> InlineMarkdown.HtmlInline(literal)
|
||||
is CMImage -> {
|
||||
val inlineContent = readInlineMarkdown(markdownProcessor)
|
||||
InlineMarkdown.Image(
|
||||
source = destination,
|
||||
alt = inlineContent.renderAsSimpleText().trim(),
|
||||
title = title,
|
||||
inlineContent = inlineContent,
|
||||
)
|
||||
is CMStrongEmphasis ->
|
||||
InlineMarkdown.StrongEmphasis(openingDelimiter, readInlineMarkdown(markdownProcessor))
|
||||
|
||||
is CMCode -> InlineMarkdown.Code(literal)
|
||||
is CMHtmlInline -> InlineMarkdown.HtmlInline(literal)
|
||||
is CMImage -> {
|
||||
val inlineContent = readInlineMarkdown(markdownProcessor)
|
||||
InlineMarkdown.Image(
|
||||
source = destination,
|
||||
alt = inlineContent.renderAsSimpleText().trim(),
|
||||
title = title,
|
||||
inlineContent = inlineContent,
|
||||
)
|
||||
}
|
||||
|
||||
is CMHardLineBreak -> InlineMarkdown.HardLineBreak
|
||||
is CMSoftLineBreak -> InlineMarkdown.SoftLineBreak
|
||||
is Delimited ->
|
||||
markdownProcessor.delimitedInlineExtensions
|
||||
.find { it.canProcess(this) }
|
||||
?.processDelimitedInline(this, markdownProcessor)
|
||||
|
||||
is ParsedInline -> null // Unsupported — see JEWEL-747
|
||||
|
||||
else -> error("Unexpected block $this")
|
||||
}
|
||||
return inlineContent to next
|
||||
}
|
||||
|
||||
is CMHardLineBreak -> InlineMarkdown.HardLineBreak
|
||||
is CMSoftLineBreak -> InlineMarkdown.SoftLineBreak
|
||||
is Delimited ->
|
||||
markdownProcessor.delimitedInlineExtensions
|
||||
.find { it.canProcess(this) }
|
||||
?.processDelimitedInline(this, markdownProcessor)
|
||||
is ParsedInline -> null // Unsupported — see JEWEL-747
|
||||
private fun getImageSize(attrs: String): Pair<DimensionSize?, DimensionSize?> =
|
||||
if (attrs.isValidImageAttributes()) parseImageAttributes(attrs) else (null to null)
|
||||
|
||||
else -> error("Unexpected block $this")
|
||||
private fun String.isValidImageAttributes(): Boolean =
|
||||
length >= 2 && first() == '{' && last() == '}' && indexOf('\n') < 0 && indexOf('\r') < 0
|
||||
|
||||
private fun parseImageAttributes(attrs: String): Pair<DimensionSize?, DimensionSize?> {
|
||||
val content = attrs.substring(1, attrs.lastIndex)
|
||||
var width: DimensionSize? = null
|
||||
var height: DimensionSize? = null
|
||||
|
||||
imageSizeAttributeRegex.findAll(content).forEach { match ->
|
||||
val name = match.groupValues[1]
|
||||
val value = match.groups[2]?.value ?: match.groups[3]?.value ?: match.groups[4]?.value.orEmpty()
|
||||
|
||||
when (name) {
|
||||
"width" -> width = value.parseDimensionSize()
|
||||
"height" -> height = value.parseDimensionSize()
|
||||
}
|
||||
}
|
||||
|
||||
return width to height
|
||||
}
|
||||
|
||||
private val imageSizeAttributeRegex = Regex("""(?:^|\s)(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))""")
|
||||
|
||||
/** Used to render content as simple plain text, used when creating image alt text. */
|
||||
internal fun List<InlineMarkdown>.renderAsSimpleText(): String = buildString {
|
||||
for (node in this@renderAsSimpleText) {
|
||||
@@ -106,3 +160,23 @@ internal fun List<InlineMarkdown>.renderAsSimpleText(): String = buildString {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseImageWithAttributes(image: InlineMarkdown.Image, next: CMText): Pair<InlineMarkdown.Image, String?>? {
|
||||
val textLiteral = next.literal
|
||||
val endAttrIdx = textLiteral.indexOf("}")
|
||||
if (!textLiteral.startsWith("{") || endAttrIdx == -1) return null
|
||||
|
||||
val (width, height) = getImageSize(textLiteral.take(endAttrIdx + 1).trim())
|
||||
if (width == null && height == null) return null // Cases like { random text } should be ignored
|
||||
|
||||
val remainder = textLiteral.substring(endAttrIdx + 1)
|
||||
|
||||
return InlineMarkdown.Image(
|
||||
source = image.source,
|
||||
alt = image.alt,
|
||||
title = image.title,
|
||||
inlineContent = image.inlineContent,
|
||||
width = width,
|
||||
height = height,
|
||||
) to remainder.takeUnless { it.isBlank() }
|
||||
}
|
||||
|
||||
+4
@@ -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(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+4
@@ -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(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+182
@@ -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("""{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("""{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("""{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("""{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("{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("{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("{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(" {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("{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("{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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
+153
-7
@@ -1,12 +1,14 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.jewel.markdown.processing.html
|
||||
|
||||
import org.jetbrains.jewel.markdown.DimensionSize
|
||||
import org.jetbrains.jewel.markdown.InlineMarkdown
|
||||
import org.jetbrains.jewel.markdown.MarkdownBlock
|
||||
import org.jetbrains.jewel.markdown.assertEquals
|
||||
import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
public class MarkdownHtmlConverterTest {
|
||||
private val processor = MarkdownProcessor(parseEmbeddedHtml = true)
|
||||
|
||||
@@ -234,14 +236,21 @@ public class MarkdownHtmlConverterTest {
|
||||
public fun `parses images -- img`() {
|
||||
val parsed =
|
||||
processor.processMarkdownDocument(
|
||||
"Look at <img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20%\"/>!"
|
||||
"Look at <img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20\"/>!"
|
||||
)
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Text("Look at "),
|
||||
InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null),
|
||||
InlineMarkdown.Image(
|
||||
source = "art/jewel-logo.svg",
|
||||
alt = "Jewel logo",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(20),
|
||||
height = null,
|
||||
inlineContent = emptyList(),
|
||||
),
|
||||
InlineMarkdown.Text("!"),
|
||||
)
|
||||
)
|
||||
@@ -251,14 +260,23 @@ public class MarkdownHtmlConverterTest {
|
||||
@Test
|
||||
public fun `parses single image in a paragraph`() {
|
||||
val parsed =
|
||||
processor.processMarkdownDocument("<img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20%\"/>")
|
||||
processor.processMarkdownDocument("<img alt=\"Jewel logo\" src=\"art/jewel-logo.svg\" width=\"20\"/>")
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("width" to "20%", "src" to "art/jewel-logo.svg", "alt" to "Jewel logo"),
|
||||
attributes = mapOf("width" to "20", "src" to "art/jewel-logo.svg", "alt" to "Jewel logo"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null))
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "art/jewel-logo.svg",
|
||||
alt = "Jewel logo",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(20),
|
||||
height = null,
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -298,7 +316,7 @@ public class MarkdownHtmlConverterTest {
|
||||
val parsed =
|
||||
processor.processMarkdownDocument(
|
||||
"""
|
||||
Please press <a href="https://example.com"><img alt="Jewel logo" src="art/jewel-logo.svg" width="20%"/></a>
|
||||
Please press <a href="https://example.com"><img alt="Jewel logo" src="art/jewel-logo.svg" width="20"/></a>
|
||||
"""
|
||||
.trimIndent()
|
||||
)
|
||||
@@ -310,7 +328,14 @@ public class MarkdownHtmlConverterTest {
|
||||
InlineMarkdown.Link(
|
||||
destination = "https://example.com",
|
||||
title = null,
|
||||
InlineMarkdown.Image(source = "art/jewel-logo.svg", alt = "Jewel logo", title = null),
|
||||
InlineMarkdown.Image(
|
||||
source = "art/jewel-logo.svg",
|
||||
alt = "Jewel logo",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(20),
|
||||
height = null,
|
||||
inlineContent = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -560,4 +585,125 @@ public class MarkdownHtmlConverterTest {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
public fun `parses img with numeric width and height`() {
|
||||
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="100" height="50">""")
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("src" to "image.png", "width" to "100", "height" to "50"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "image.png",
|
||||
alt = "",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(100),
|
||||
height = DimensionSize.Pixels(50),
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
public fun `parses img with extra suffix in size attributes`() {
|
||||
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="100px;" height="50px;">""")
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("src" to "image.png", "width" to "100px;", "height" to "50px;"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "image.png",
|
||||
alt = "",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(100),
|
||||
height = DimensionSize.Pixels(50),
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
public fun `parses img with only width specified`() {
|
||||
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="200">""")
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("src" to "image.png", "width" to "200"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "image.png",
|
||||
alt = "",
|
||||
title = null,
|
||||
width = DimensionSize.Pixels(200),
|
||||
height = null,
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
public fun `parses img with only height specified`() {
|
||||
val parsed = processor.processMarkdownDocument("""<img src="image.png" height="150">""")
|
||||
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("src" to "image.png", "height" to "150"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "image.png",
|
||||
alt = "",
|
||||
title = null,
|
||||
width = null,
|
||||
height = DimensionSize.Pixels(150),
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
public fun `parses img with invalid width -- returns null for width`() {
|
||||
val parsed = processor.processMarkdownDocument("""<img src="image.png" width="auto">""")
|
||||
|
||||
// Invalid values like "auto" should not be parsed
|
||||
parsed.assertEquals(
|
||||
MarkdownBlock.HtmlBlockWithAttributes(
|
||||
attributes = mapOf("src" to "image.png", "width" to "auto"),
|
||||
mdBlock =
|
||||
MarkdownBlock.Paragraph(
|
||||
listOf(
|
||||
InlineMarkdown.Image(
|
||||
source = "image.png",
|
||||
alt = "",
|
||||
title = null,
|
||||
width = null,
|
||||
height = null,
|
||||
inlineContent = emptyList(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+94
-18
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+266
-2
@@ -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) } }
|
||||
|
||||
Reference in New Issue
Block a user