Skip to content
Merged
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<TextView>? = null
private var sourceDrawable: Drawable? = null

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

Expand Down Expand Up @@ -125,6 +129,7 @@ object MarkdownRenderTestSupport {
codeBlockStyle: CodeBlockStyle? = null,
spoilerStyle: SpoilerStyle? = null,
linkStyle: LinkStyle? = null,
inlineImageStyle: InlineImageStyle? = null,
paragraphStyle: ParagraphStyle? = null,
headingStyles: Array<HeadingStyle?>? = null,
): StyleConfig {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<TextView>? = null
private var sourceDrawable: Drawable? = null
private var onBoxHeightChanged: (() -> Unit)? = null
Expand Down Expand Up @@ -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?,
Expand Down Expand Up @@ -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())
}
Expand Down
Loading