From ecb6693c0cf9fc5265aeb7fca68ef6623b299567 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Sun, 3 May 2026 20:38:50 -0700 Subject: [PATCH 1/2] Normalize captioned Markdown images before rendering --- .../ui/chat/ChatMessageContent.kt | 15 +++++--- .../ui/files/FilePreviewPane.kt | 9 +++-- .../ui/util/MarkdownImageResolver.kt | 30 +++++++++++++++ .../MarkdownImageResolverTest.kt | 37 +++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatMessageContent.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatMessageContent.kt index 6fa2bfc..a604083 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatMessageContent.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatMessageContent.kt @@ -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) } } } @@ -361,15 +362,16 @@ private fun ResolvedMarkdownText( modifier: Modifier = Modifier ) { var resolvedText by remember(text, workspaceDirectory) { mutableStateOf(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) @@ -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 @@ -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 diff --git a/app/src/main/java/com/yage/opencode_client/ui/files/FilePreviewPane.kt b/app/src/main/java/com/yage/opencode_client/ui/files/FilePreviewPane.kt index 3c8623c..ce3a51e 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/files/FilePreviewPane.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/files/FilePreviewPane.kt @@ -114,19 +114,20 @@ private fun PreviewMarkdown( sessionDirectory: String? ) { var resolvedContent by remember(content, filePath) { mutableStateOf(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) @@ -139,7 +140,7 @@ private fun PreviewMarkdown( ) { item { Markdown( - content = resolvedContent ?: content, + content = resolvedContent ?: normalizedContent, typography = markdownTypographyCompact(), modifier = Modifier.fillMaxWidth(), imageTransformer = DataUriImageTransformer diff --git a/app/src/main/java/com/yage/opencode_client/ui/util/MarkdownImageResolver.kt b/app/src/main/java/com/yage/opencode_client/ui/util/MarkdownImageResolver.kt index 2238dcd..e01fe11 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/util/MarkdownImageResolver.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/util/MarkdownImageResolver.kt @@ -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(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, @@ -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 + } } diff --git a/app/src/test/java/com/yage/opencode_client/MarkdownImageResolverTest.kt b/app/src/test/java/com/yage/opencode_client/MarkdownImageResolverTest.kt index 7b096bf..1a78915 100644 --- a/app/src/test/java/com/yage/opencode_client/MarkdownImageResolverTest.kt +++ b/app/src/test/java/com/yage/opencode_client/MarkdownImageResolverTest.kt @@ -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( From df1aa8be516ca68a8eb7e1efd6723aef107e7456 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Sun, 3 May 2026 20:38:50 -0700 Subject: [PATCH 2/2] Document Markdown caption image rendering fix --- docs/working.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/working.md b/docs/working.md index 6a3c94b..b3901b4 100644 --- a/docs/working.md +++ b/docs/working.md @@ -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