Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions platform/jewel/markdown/core/api-dump-experimental.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,12 +66,16 @@
- 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.DimensionSize,org.jetbrains.jewel.markdown.DimensionSize,java.util.List):V
- b:<init>(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
- <init>(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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%"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
) : InlineMarkdown, WithInlineMarkdown {
public constructor(
Expand All @@ -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<InlineMarkdown>,
) : this(source, alt, title, null, null, inlineContent)

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -147,7 +162,9 @@ public sealed interface InlineMarkdown {
"source='$source', " +
"alt='$alt', " +
"title=$title, " +
"inlineContent=$inlineContent" +
"inlineContent=$inlineContent, " +
"width=$width, " +
"height=$height" +
")"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,10 +34,10 @@ 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.toInlineMarkdownOrNull(markdownProcessor)
if (inline != null) add(inline)

current = current.next
current = next
}
}
return markdownProcessor.convertHtmlInlines(inlines)
Expand All @@ -53,43 +54,108 @@ public fun Node.readInlineMarkdown(markdownProcessor: MarkdownProcessor): List<I
*/
@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),
)

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<InlineMarkdown?, Node?> {
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<DimensionSize?, DimensionSize?> =
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<DimensionSize?, DimensionSize?> {
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<InlineMarkdown>.renderAsSimpleText(): String = buildString {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
)
}
Expand Down
Loading
Loading