diff --git a/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png b/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png index 6c1b92b35..87cc3edfe 100644 Binary files a/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png and b/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/inline_image_display.png b/.maestro/enrichedMarkdownText/screenshots/android/inline_image_display.png index 84f2ca5ac..f8d2f642a 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/inline_image_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/inline_image_display.png differ diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt index 8d3248c49..dfd3545f6 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt @@ -8,6 +8,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build @@ -28,6 +29,8 @@ import com.swmansion.enriched.markdown.utils.text.ImageDownloader import com.swmansion.enriched.markdown.utils.text.LocalImageLoader import java.lang.ref.WeakReference import java.util.concurrent.Executors +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToInt import android.text.style.ImageSpan as AndroidImageSpan import android.text.style.LineHeightSpan as AndroidLineHeightSpan @@ -52,6 +55,7 @@ class ImageSpan( private val requestKey: String = ImageCache.requestKey(imageUrl, requestHeaders) private var cachedWidth: Int = 0 + private val capBounds = Rect() private var viewRef: WeakReference? = null private var sourceDrawable: Drawable? = null @@ -184,7 +188,24 @@ class ImageSpan( start: Int, end: Int, fm: Paint.FontMetricsInt?, - ): Int = getDrawable().bounds.right + ): Int { + if (isInline && fm != null) { + // The line grows to fit a tall image, as iOS grows it for an attachment. + paint.getFontMetricsInt(fm) + val imageTop = inlineImageTop(paint) + fm.ascent = minOf(fm.ascent, floor(imageTop).toInt()) + fm.top = minOf(fm.top, fm.ascent) + fm.descent = maxOf(fm.descent, ceil(imageTop + height).toInt()) + fm.bottom = maxOf(fm.bottom, fm.descent) + } + return getDrawable().bounds.right + } + + /** The inline image's top relative to the baseline: centered on the cap height, as iOS places inline attachments. */ + private fun inlineImageTop(paint: Paint): Float { + paint.getTextBounds("H", 0, 1, capBounds) + return -(capBounds.height() + height) / 2f + } override fun chooseHeight( text: CharSequence?, @@ -221,8 +242,7 @@ class ImageSpan( val drawable = getDrawable() canvas.withSave { if (isInline) { - val imageHeight = drawable.bounds.height() - translate(x, (y - imageHeight + (imageHeight * 0.1f))) + translate(x, y + inlineImageTop(paint)) } else { translate(x, top.toFloat()) } diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/InlineImagePlacementTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/InlineImagePlacementTest.kt new file mode 100644 index 000000000..34dd0237c --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/InlineImagePlacementTest.kt @@ -0,0 +1,100 @@ +package com.swmansion.enriched.markdown + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.text.Layout +import android.util.TypedValue +import android.view.View +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.enriched.markdown.spans.registerWithSpans +import com.swmansion.enriched.markdown.styles.InlineImageStyle +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render +import com.swmansion.enriched.markdown.test.TestAstFactory.document +import com.swmansion.enriched.markdown.test.TestAstFactory.image +import com.swmansion.enriched.markdown.test.TestAstFactory.paragraph +import com.swmansion.enriched.markdown.test.TestAstFactory.text +import com.swmansion.enriched.markdown.utils.text.ImageCache +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +// Legacy graphics report zero font metrics, so the cap height this checks against would be zero. +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class InlineImagePlacementTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + // iOS centers an inline attachment on the font's cap height; Android used to sit the image on + // the baseline, so an icon taller than the capitals stood above the text. + @Test + fun anInlineImageIsCenteredOnTheCapHeight() { + val placement = placeInlineImage(MarkdownRenderTestSupport.defaultStyle) + + assertEquals(placement.capCenter, placement.imageCenter, 1f) + } + + // iOS grows the line for an attachment taller than the text; Android kept the text's line + // height, so the view clipped the image. + @Test + fun aTallInlineImageGrowsItsLine() { + val defaultStyle = MarkdownRenderTestSupport.defaultStyle + val size = defaultStyle.paragraphStyle.fontSize * 3 + val placement = placeInlineImage(MarkdownRenderTestSupport.styleWithInlineImage(InlineImageStyle(size))) + + assertEquals(size.toInt(), placement.imageRows.count()) + assertTrue(placement.imageRows.first >= placement.layout.getLineTop(0)) + assertTrue(placement.imageRows.last < placement.layout.getLineBottom(0)) + assertEquals(placement.capCenter, placement.imageCenter, 1f) + } + + private class Placement( + val layout: Layout, + val imageRows: IntRange, + val capCenter: Float, + ) { + val imageCenter: Float get() = (imageRows.first + imageRows.last + 1) / 2f + } + + /** Draws a red inline image between text and finds the rows it painted. */ + private fun placeInlineImage(style: StyleConfig): Placement { + val url = "test://inline-image-placement" + ImageCache.putOriginal(url, Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }) + val rendered = render(document(paragraph(text("Inline "), image(url), text(" image"))), style) + val textView = TextView(context) + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.paragraphStyle.fontSize) + textView.text = rendered + textView.registerWithSpans(rendered) + textView.measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + textView.layout(0, 0, 400, textView.measuredHeight) + val layout = requireNotNull(textView.layout) + + val bitmap = Bitmap.createBitmap(400, textView.height, Bitmap.Config.ARGB_8888) + layout.draw(Canvas(bitmap)) + val redRows = + (0 until bitmap.height).filter { y -> + (0 until bitmap.width).any { x -> + val pixel = bitmap.getPixel(x, y) + Color.alpha(pixel) > 0 && Color.red(pixel) > 128 && Color.green(pixel) < 64 + } + } + + val capBounds = Rect() + textView.paint.getTextBounds("H", 0, 1, capBounds) + val capCenter = layout.getLineBaseline(0) - capBounds.height() / 2f + return Placement(layout, redRows.first()..redRows.last(), capCenter) + } +} diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt index c68628907..8298776a7 100644 --- a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt @@ -15,6 +15,7 @@ import com.swmansion.enriched.markdown.styles.CodeBlockStyle import com.swmansion.enriched.markdown.styles.CodeStyle import com.swmansion.enriched.markdown.styles.HeadingStyle import com.swmansion.enriched.markdown.styles.HighlightStyle +import com.swmansion.enriched.markdown.styles.InlineImageStyle import com.swmansion.enriched.markdown.styles.LinkStyle import com.swmansion.enriched.markdown.styles.ListStyle import com.swmansion.enriched.markdown.styles.ParagraphStyle @@ -90,6 +91,9 @@ object MarkdownRenderTestSupport { /** [defaultStyle] with only its [SpoilerStyle] replaced. */ fun styleWithSpoiler(spoilerStyle: SpoilerStyle): StyleConfig = copyOfDefault(spoilerStyle = spoilerStyle) + /** [defaultStyle] with only its [InlineImageStyle] replaced. */ + fun styleWithInlineImage(inlineImageStyle: InlineImageStyle): StyleConfig = copyOfDefault(inlineImageStyle = inlineImageStyle) + /** [defaultStyle] with only its [LinkStyle] replaced. */ fun styleWithLink(linkStyle: LinkStyle): StyleConfig = copyOfDefault(linkStyle = linkStyle) @@ -125,6 +129,7 @@ object MarkdownRenderTestSupport { codeBlockStyle: CodeBlockStyle? = null, spoilerStyle: SpoilerStyle? = null, linkStyle: LinkStyle? = null, + inlineImageStyle: InlineImageStyle? = null, paragraphStyle: ParagraphStyle? = null, headingStyles: Array? = null, ): StyleConfig { @@ -143,7 +148,7 @@ object MarkdownRenderTestSupport { subscriptStyle = base.subscriptStyle, codeStyle = codeStyle ?: base.codeStyle, imageStyle = base.imageStyle, - inlineImageStyle = base.inlineImageStyle, + inlineImageStyle = inlineImageStyle ?: base.inlineImageStyle, blockquoteStyle = blockquoteStyle ?: base.blockquoteStyle, listStyle = listStyle ?: base.listStyle, taskListStyle = taskListStyle ?: base.taskListStyle, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt index 2d0109e32..03c1f5b77 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt @@ -6,6 +6,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build @@ -27,6 +28,8 @@ import com.swmansion.enriched.markdown.utils.text.ImageDownloader import com.swmansion.enriched.markdown.utils.text.LocalImageLoader import java.lang.ref.WeakReference import java.util.concurrent.Executors +import kotlin.math.ceil +import kotlin.math.floor import android.text.style.ImageSpan as AndroidImageSpan import android.text.style.LineHeightSpan as AndroidLineHeightSpan @@ -75,6 +78,7 @@ class ImageSpan( private val requestKey: String = ImageCache.requestKey(requestUrl, requestHeaders) private var cachedWidth: Int = 0 + private val capBounds = Rect() private var viewRef: WeakReference? = null private var sourceDrawable: Drawable? = null private var onBoxHeightChanged: (() -> Unit)? = null @@ -280,7 +284,24 @@ class ImageSpan( start: Int, end: Int, fm: Paint.FontMetricsInt?, - ): Int = getDrawable().bounds.right + ): Int { + if (isInline && fm != null) { + // The line grows to fit a tall image, as iOS grows it for an attachment. + paint.getFontMetricsInt(fm) + val imageTop = inlineImageTop(paint) + fm.ascent = minOf(fm.ascent, floor(imageTop).toInt()) + fm.top = minOf(fm.top, fm.ascent) + fm.descent = maxOf(fm.descent, ceil(imageTop + height).toInt()) + fm.bottom = maxOf(fm.bottom, fm.descent) + } + return getDrawable().bounds.right + } + + /** The inline image's top relative to the baseline: centered on the cap height, as iOS places inline attachments. */ + private fun inlineImageTop(paint: Paint): Float { + paint.getTextBounds("H", 0, 1, capBounds) + return -(capBounds.height() + height) / 2f + } override fun chooseHeight( text: CharSequence?, @@ -313,8 +334,7 @@ class ImageSpan( val drawable = getDrawable() canvas.withSave { if (isInline) { - val imageHeight = drawable.bounds.height() - translate(x, (y - imageHeight + (imageHeight * 0.1f))) + translate(x, y + inlineImageTop(paint)) } else { translate(x, top.toFloat()) }