Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,10 @@ private fun TextPart(
modifier = innerModifier
)
} else {
val normalizedText = remember(text) { MarkdownImageResolver.normalizeStandaloneImageBlocks(text) }
SelectionContainer {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Markdown(content = text, typography = markdownTypographyCompact(), modifier = innerModifier, imageTransformer = DataUriImageTransformer)
Markdown(content = normalizedText, typography = markdownTypographyCompact(), modifier = innerModifier, imageTransformer = DataUriImageTransformer)
}
}
}
Expand All @@ -361,15 +362,16 @@ private fun ResolvedMarkdownText(
modifier: Modifier = Modifier
) {
var resolvedText by remember(text, workspaceDirectory) { mutableStateOf<String?>(null) }
val normalizedText = remember(text) { MarkdownImageResolver.normalizeStandaloneImageBlocks(text) }

LaunchedEffect(text, workspaceDirectory, repository) {
LaunchedEffect(normalizedText, workspaceDirectory, repository) {
resolvedText = null
resolvedText = MarkdownImageResolver.resolveImages(
text = text,
text = normalizedText,
workspaceDirectory = workspaceDirectory,
fetchContent = { path -> repository.getFileContent(path).getOrThrow() }
)
val finalText = resolvedText ?: text
val finalText = resolvedText ?: normalizedText
val httpsUrls = """!\[[^\]]*\]\((https?://[^)]+)\)""".toRegex().findAll(finalText).map { it.groupValues[1] }.toList().distinct()
for (url in httpsUrls) {
HttpImageHolder.prefetch(url)
Expand All @@ -379,7 +381,7 @@ private fun ResolvedMarkdownText(
SelectionContainer {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Markdown(
content = resolvedText ?: text,
content = resolvedText ?: normalizedText,
typography = markdownTypographyCompact(),
modifier = modifier,
imageTransformer = DataUriImageTransformer
Expand Down Expand Up @@ -427,10 +429,11 @@ private fun ReasoningCard(
}
}
if ((expanded || isStreaming) && text.isNotBlank()) {
val normalizedText = remember(text) { MarkdownImageResolver.normalizeStandaloneImageBlocks(text) }
SelectionContainer {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Markdown(
content = text,
content = normalizedText,
typography = markdownTypographyCompact(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
imageTransformer = DataUriImageTransformer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,20 @@ private fun PreviewMarkdown(
sessionDirectory: String?
) {
var resolvedContent by remember(content, filePath) { mutableStateOf<String?>(null) }
val normalizedContent = remember(content) { MarkdownImageResolver.normalizeStandaloneImageBlocks(content) }
val resolverMarkdownPath = remember(filePath, sessionDirectory) {
resolveRelativePreviewPath(filePath, sessionDirectory)
}

LaunchedEffect(content, resolverMarkdownPath, sessionDirectory, repository) {
LaunchedEffect(normalizedContent, resolverMarkdownPath, sessionDirectory, repository) {
resolvedContent = null
resolvedContent = MarkdownImageResolver.resolveImages(
text = content,
text = normalizedContent,
markdownFilePath = resolverMarkdownPath,
workspaceDirectory = sessionDirectory,
fetchContent = { path -> repository.getFileContent(path).getOrThrow() }
)
val finalText = resolvedContent ?: content
val finalText = resolvedContent ?: normalizedContent
val httpsUrls = """!\[[^\]]*\]\((https?://[^)]+)\)""".toRegex().findAll(finalText).map { it.groupValues[1] }.toList().distinct()
for (url in httpsUrls) {
HttpImageHolder.prefetch(url)
Expand All @@ -139,7 +140,7 @@ private fun PreviewMarkdown(
) {
item {
Markdown(
content = resolvedContent ?: content,
content = resolvedContent ?: normalizedContent,
typography = markdownTypographyCompact(),
modifier = Modifier.fillMaxWidth(),
imageTransformer = DataUriImageTransformer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ object MarkdownImageResolver {

private val imagePattern = Regex("""!\[([^\]]*)\]\(([^)]+)\)""")

fun normalizeStandaloneImageBlocks(text: String): String {
val lines = text.split('\n')
if (lines.size <= 1) return text

val normalized = ArrayList<String>(lines.size)
for (index in lines.indices) {
val line = lines[index]
normalized.add(line)

if (!isStandaloneMarkdownImageLine(line)) continue
if (index + 1 >= lines.size) continue

val nextLine = lines[index + 1]
if (nextLine.trim().isNotEmpty()) {
normalized.add("")
}
}

return normalized.joinToString("\n")
}

suspend fun resolveImages(
text: String,
markdownFilePath: String? = null,
Expand Down Expand Up @@ -128,4 +149,13 @@ object MarkdownImageResolver {
else -> "image/*"
}
}

private fun isStandaloneMarkdownImageLine(line: String): Boolean {
val trimmed = line.trim()
if (!trimmed.startsWith("![") || !trimmed.endsWith(")")) return false
val closeAlt = trimmed.indexOf(']')
if (closeAlt < 0 || closeAlt + 1 >= trimmed.length) return false
val afterAlt = trimmed.substring(closeAlt + 1)
return afterAlt.startsWith("(") && afterAlt.length > 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,43 @@ import org.junit.Assert.assertTrue
import org.junit.Test

class MarkdownImageResolverTest {
@Test
fun `normalizeStandaloneImageBlocks separates image from caption`() {
val markdown = """
![雍和宫入口](https://example.com/yonghe.jpg)
*图注文字*
""".trimIndent()

val normalized = MarkdownImageResolver.normalizeStandaloneImageBlocks(markdown)

assertEquals(
"""
![雍和宫入口](https://example.com/yonghe.jpg)

*图注文字*
""".trimIndent(),
normalized
)
}

@Test
fun `normalizeStandaloneImageBlocks leaves existing blank line`() {
val markdown = """
![chart](assets/chart.png)

Caption
""".trimIndent()

assertEquals(markdown, MarkdownImageResolver.normalizeStandaloneImageBlocks(markdown))
}

@Test
fun `normalizeStandaloneImageBlocks keeps true inline image text unchanged`() {
val markdown = "Before ![inline](assets/icon.png) after"

assertEquals(markdown, MarkdownImageResolver.normalizeStandaloneImageBlocks(markdown))
}

@Test
fun `normalizeImagePath resolves markdown relative path into workspace path`() {
val resolved = MarkdownImageResolver.normalizeImagePath(
Expand Down
1 change: 1 addition & 0 deletions docs/working.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 2026-05-03

- 模型预设里的 GLM 选项从 `GLM-5-turbo` / `glm-5-turbo` 更新为 `GLM-5.1` / `glm-5.1`,对齐 iOS 客户端。
- 修复 Markdown 图片后紧跟 caption 时的渲染问题:`MarkdownImageResolver.normalizeStandaloneImageBlocks()` 会在渲染前把单独一行图片与下一行非空文本分开,避免 renderer 把图片当成 paragraph 内 inline image,导致图片尺寸异常。File Preview、Chat 消息和 Reasoning 卡片渲染前统一应用该 normalizer,并新增 3 个单元测试覆盖 caption、已有空行和真正 inline image 三种情况。验证:`./gradlew testDebugUnitTest` 通过。

## 2026-05-02

Expand Down
Loading