Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.
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 @@ -46,3 +46,33 @@ export const Default: TextStory<InlineImageStyleControls> = {
);
},
};

const NON_SQUARE_MARKDOWN = [
'Wide ![wide](https://placehold.co/400x200/2563eb/ffffff/png?text=WIDE) Catan Hxg',
'Tall ![tall](https://placehold.co/200x400/dc2626/ffffff/png?text=TALL) Catan Hxg',
'Square ![square](https://placehold.co/300x300/16a34a/ffffff/png?text=SQ) Catan Hxg',
'Very wide ![very wide](https://placehold.co/1000x100/7c3aed/ffffff/png?text=10:1) and very tall ![very tall](https://placehold.co/100x1000/ea580c/ffffff/png?text=T) Hxg',
].join('\n\n');

export const NonSquare: TextStory<InlineImageStyleControls> = {
args: {
markdown: NON_SQUARE_MARKDOWN,
...inlineImageStyledDefaults,
size: 48,
},
argTypes,
render: (args) => {
const { controls, rest } = splitStyleControls(
args,
inlineImageStyledDefaults
);
return (
<EnrichedMarkdownTextStory
title="Inline Image: non-square"
description="A non-square image fits inside its markdownStyle.inlineImage.size square without distortion or cropping (contain), centred on the cap height."
{...rest}
style={{ inlineImage: toInlineImageStyle(controls) }}
/>
);
},
};
2 changes: 1 addition & 1 deletion docs-md/ELEMENTS_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Each blank line in the source renders as one empty line, so the output keeps the
Images are automatically detected as block or inline based on context:

- **Block images**: When an image is the only content in a paragraph (standalone), it's treated as a block image and uses block-level spacing
- **Inline images**: When an image appears alongside other text content, it's treated as inline and aligns with the text baseline
- **Inline images**: When an image appears alongside other text content, it's treated as inline: it sits in the text flow, centred on the text's cap height, in a square of `inlineImage.size`. The image is scaled up or down to fit inside that square, keeping its proportions, so it is never distorted or cropped

You don't need to specify which type—the renderer automatically determines this based on the image's position in the content. Note that a single newline doesn't split a paragraph, so an image on its own source line directly below text is still inline; separate it with a blank line to make it a block image.

Expand Down
2 changes: 1 addition & 1 deletion docs-md/STYLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ Videos render as native player views — `AVPlayerViewController` on iOS and Exo

| Property | Type | Description |
|----------|------|-------------|
| `size` | `number` | Image size (square) |
| `size` | `number` | Side length of the square the image is drawn in; the image is scaled up or down to fit inside it, keeping its proportions |

### Thematic Break (Horizontal Rule)-specific

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/android/api-reference/element-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ In either layout the language label is parsed. To color code by it, add `CodeHig
The same `![alt](url)` syntax renders two different ways, and which one you get depends on **where the image sits on its line**:

- **Block image** - nothing comes before the image on its line: it opens a paragraph, or follows a hard line break. It is laid out on its own, at `image.height`, spanning the container width. This holds even when text follows it on the same line.
- **Inline image** - text comes before it on the line. It is drawn in the text flow at `inlineImage.size`, sized to sit on the line like a large glyph.
- **Inline image** - text comes before it on the line. It is drawn in the text flow in a square of `inlineImage.size`, centred on the cap height of the text around it, like a large glyph. The image is scaled up or down to fit inside that square, keeping its proportions, so it is never distorted or cropped.

:::note
This differs from CommonMark, where every image is inline. Markdown has no block image syntax; the split is how this renderer lays images out.
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/android/api-reference/style-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,9 +472,9 @@ markdownStyle {

An image sitting inside a line of text, sized to the line rather than the container.

| Property | Type | Default | Description |
| -------- | ---- | ------- | ------------------------------------ |
| `size` | `Dp` | `20.dp` | Width and height of the inline image |
| Property | Type | Default | Description |
| -------- | ---- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `size` | `Dp` | `20.dp` | Side length of the square the image is drawn in. The image is scaled up or down to fit inside it, keeping its proportions |

```kotlin
markdownStyle {
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/ios/api-reference/element-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ A tap reveals only the spoiler it lands on, and that spoiler stays revealed unti
The same `![alt](url)` syntax renders two different ways, and which one you get depends on **what else is in the paragraph**:

- **Block image** - the image is the only thing in its paragraph. It is laid out on its own, spanning the container width, with its height set by [`BlockImage()`](/ios/api-reference/style-properties#blockimage): a fixed `height`, a `maxHeight` cap on the image's own proportions, or an `aspectRatio`, with `contentMode` deciding how the image fills that box. The width is always the available width.
- **Inline image** - anything else shares the line with it. It is drawn in the text flow at `InlineImage().size`, sized to sit on the line like a large glyph.
- **Inline image** - anything else shares the line with it. It is drawn in the text flow in a square of `InlineImage().size`, centred on the cap height of the text around it, like a large glyph. The image is scaled up or down to fit inside that square, keeping its proportions, so it is never distorted or cropped.

One edge case: an image that **starts** a paragraph is drawn block-sized even when text follows it on the same line. Only an image that comes after some text in its paragraph is drawn inline.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/ios/api-reference/style-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ An image sharing a line with text, drawn like an oversized glyph. One modifier:

| Modifier | Type | Default | Description |
| --- | --- | --- | --- |
| `.size(_:)` | `CGFloat` | `20` | Side length in points |
| `.size(_:)` | `CGFloat` | `20` | Side length in points of the square the image is drawn in. The image is scaled up or down to fit inside it, keeping its proportions, as with `.fit` |

```swift
MarkdownTheme {
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/react-native/api-reference/element-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ Requires both [`flavor="github"`](/react-native/api-reference/enriched-markdown-
Images are automatically detected as block or inline based on context:

- **Block images** use block-level spacing and sit on their own line.
- **Inline images** align with the text baseline inside the surrounding run of text.
- **Inline images** sit inside the surrounding run of text, centred on its cap height, in a square of `inlineImage.size`. The image is scaled up or down to fit inside that square, keeping its proportions, so it is never distorted or cropped.

You don't need to specify which type - the renderer determines it from the image's position. Note that a single newline does not split a paragraph, so an image on its own source line directly below text is still inline; separate it with a blank line to make it a block image.

Expand Down
6 changes: 3 additions & 3 deletions docs/docs/react-native/api-reference/style-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,9 @@ On native, requires [`flavor="github"`](/react-native/api-reference/enriched-mar

### Inline image-specific

| Property | Type | Default | Description |
| -------- | -------- | ------- | ------------------- |
| `size` | `number` | `20` | Image size (square) |
| Property | Type | Default | Description |
| -------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `size` | `number` | `20` | Side length of the square the image is drawn in. The image is scaled up or down to fit inside it, keeping its proportions |

<LivePreview src={InlineImageSrc} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const markdown = `An inline icon ![icon](${src}) sits in the text flow.`;
export default function App() {
const isDark = useColorScheme() === 'dark';

// An inline image sits within a line of text. Its only style is size, which
// renders it as a square scaled to the surrounding text.
// An inline image sits within a line of text. Its only style is size: the side
// of the square the image fits inside, scaled to the surrounding text.
const markdownStyle = useMemo(
() => ({
...defaultMarkdownStyle(isDark),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class ImageSpan(
TextViewAwareSpan {
private var loadedDrawable: Drawable? = null
private val height: Int = if (isInline) styleConfig.inlineImageStyle.size.toInt() else styleConfig.imageStyle.height.toInt()
private val borderRadiusPx: Int = (styleConfig.imageStyle.borderRadius * context.resources.displayMetrics.density).toInt()
private val borderRadiusPx: Int = styleConfig.imageStyle.borderRadius.toInt()
private val requestKey: String = ImageCache.requestKey(imageUrl, requestHeaders)

private var cachedWidth: Int = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.swmansion.enriched.markdown

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
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.styles.InlineImageStyle
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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

// The corner radius is already in pixels; scaling it by the density again made a square a circle.
@RunWith(AndroidJUnit4::class)
@Config(sdk = [28], qualifiers = "xxhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ImageCornerRadiusTest {
private val context: Context = ApplicationProvider.getApplicationContext()

@Test
fun aSquareImageKeepsItsCornerRadius() {
val radius = MarkdownRenderTestSupport.defaultStyle.imageStyle.borderRadius
val size = radius * 5
val url = "test://image-corner-radius"
ImageCache.putOriginal(url, Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) })
val style = MarkdownRenderTestSupport.styleWithInlineImage(InlineImageStyle(size))
val rendered = render(document(paragraph(text("A "), image(url), text(" B"))), style)

val textView = TextView(context)
textView.text = rendered
textView.measure(
View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
)
textView.layout(0, 0, WIDTH, textView.measuredHeight)
val bitmap = Bitmap.createBitmap(WIDTH, textView.height, Bitmap.Config.ARGB_8888)
requireNotNull(textView.layout).draw(Canvas(bitmap))

fun isRed(
x: Int,
y: Int,
): Boolean {
val pixel = bitmap.getPixel(x, y)
return Color.alpha(pixel) > 200 && Color.red(pixel) > 200 && Color.green(pixel) < 64
}
val left = (0 until WIDTH).first { x -> (0 until bitmap.height).any { y -> isRed(x, y) } }
val top = (0 until bitmap.height).first { y -> (0 until WIDTH).any { x -> isRed(x, y) } }

assertFalse(isRed(left, top))
val inset = (radius / 2).toInt()
assertTrue(isRed(left + inset, top + inset))
}

private companion object {
const val WIDTH = 1200
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,42 @@ class InlineImagePlacementTest {
assertEquals(placement.capCenter, placement.imageCenter, 1f)
}

// A 2:1 image spans the square's width and half its height (contain).
@Test
fun aWideInlineImageFitsInsideItsSquare() {
val size = MarkdownRenderTestSupport.defaultStyle.paragraphStyle.fontSize * 2
val placement =
placeInlineImage(
MarkdownRenderTestSupport.styleWithInlineImage(InlineImageStyle(size)),
imageWidth = 64,
imageHeight = 32,
)

assertEquals(size, placement.imageColumns.count().toFloat(), 1f)
assertEquals(size / 2, placement.imageRows.count().toFloat(), 1f)
assertEquals(placement.capCenter, placement.imageCenter, 1f)
}

private class Placement(
val layout: Layout,
val imageRows: IntRange,
val imageColumns: 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) })
/** Draws a red inline image between text and finds the rows and columns it painted. */
private fun placeInlineImage(
style: StyleConfig,
imageWidth: Int = 32,
imageHeight: Int = 32,
): Placement {
val url = "test://inline-image-placement-${imageWidth}x$imageHeight"
ImageCache.putOriginal(
url,
Bitmap.createBitmap(imageWidth, imageHeight, 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)
Expand All @@ -84,17 +108,21 @@ class InlineImagePlacementTest {

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
}
}

fun isRed(
x: Int,
y: Int,
): Boolean {
val pixel = bitmap.getPixel(x, y)
return Color.alpha(pixel) > 0 && Color.red(pixel) > 128 && Color.green(pixel) < 64
}

val redRows = (0 until bitmap.height).filter { y -> (0 until bitmap.width).any { x -> isRed(x, y) } }
val redColumns = (0 until bitmap.width).filter { x -> (0 until bitmap.height).any { y -> isRed(x, y) } }

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)
return Placement(layout, redRows.first()..redRows.last(), redColumns.first()..redColumns.last(), capCenter)
}
}
2 changes: 1 addition & 1 deletion packages/enriched-markdown-ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ Three things worth knowing:
- `.cornerRadius` rounds the drawn image, not the box, so with `.fit`, `.scaleDown` or `.original` the corners follow the image.
- `.scaleDown` and `.original` draw the decoded image, and decoding is capped at the screen's pixel width, so a very large image is not drawn at its full pixel size.

Inline images ignore all four modifiers. They are always a square of `InlineImage().size`.
Inline images ignore the sizing and content mode modifiers. They always take a square of `InlineImage().size`, and the image is scaled up or down to fit inside it, keeping its proportions, as with `.fit`. `.cornerRadius` still rounds them, at most into a circle.

## Accessibility

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ final class MarkdownImageAttachment: NSTextAttachment {
let contentMode: ImageContentMode

weak var layoutObserver: MarkdownAttachmentLayoutObserver?
var textFont: UIFont?
var lineHeightOffset: CGFloat?

private let requestKey: String
/// The processed-cache key less the box, which is all that varies per pass.
Expand Down Expand Up @@ -94,8 +96,8 @@ final class MarkdownImageAttachment: NSTextAttachment {
self.downloader = downloader
let sizing = config.image.sizing ?? .height(ImageSizing.fallbackHeight)
self.sizing = sizing
// Inline images fill their square exactly.
contentMode = isInline ? .stretch : (config.image.contentMode ?? sizing.defaultContentMode)
// Inline images fit inside their square (contain).
contentMode = isInline ? .fit : (config.image.contentMode ?? sizing.defaultContentMode)
cachedHeight = isInline ? (config.inlineImage.size ?? 20) : sizing.placeholderHeight
cachedBorderRadius = config.image.cornerRadius ?? 0
processedKeyPrefix = "\(requestKey)_r\(cachedBorderRadius)_m\(contentMode.rawValue)"
Expand All @@ -119,21 +121,9 @@ final class MarkdownImageAttachment: NSTextAttachment {
adopt(textContainer)

if isInline {
let size = cachedHeight
var appliedFont: UIFont?
if let textStorage = textStorage(from: textContainer),
charIndex >= 0,
charIndex < textStorage.length {
appliedFont = textStorage.attribute(.font, at: charIndex, effectiveRange: nil) as? UIFont
}

let verticalOffset: CGFloat
if let appliedFont {
verticalOffset = (appliedFont.capHeight - size) / 2
} else {
verticalOffset = (lineFragmentRect.height - size) / 2
}
return CGRect(x: 0, y: verticalOffset, width: size, height: size)
var rect = inlineBounds(lineFragmentRect: lineFragmentRect)
rect.origin.y += lineHeightOffset ?? 0
return rect
}

let width = lineFragmentRect.width > 0 ? lineFragmentRect.width : cachedHeight
Expand All @@ -142,6 +132,12 @@ final class MarkdownImageAttachment: NSTextAttachment {
return CGRect(origin: .zero, size: box)
}

private func inlineBounds(lineFragmentRect: CGRect) -> CGRect {
let size = cachedHeight
let verticalOffset = textFont.map { ($0.capHeight - size) / 2 } ?? (lineFragmentRect.height - size) / 2
return CGRect(x: 0, y: verticalOffset, width: size, height: size)
}

override func image(
forBounds imageBounds: CGRect,
textContainer: NSTextContainer?,
Expand Down Expand Up @@ -259,10 +255,18 @@ final class MarkdownImageAttachment: NSTextAttachment {

let drawingRect = ImageDrawing.rect(mode: contentMode, source: image.size, box: box)
let renderer = UIGraphicsImageRenderer(size: box)
return renderer.image { _ in
return renderer.image { context in
if borderRadius > 0 {
let clippingRect = drawingRect.intersection(CGRect(origin: .zero, size: box))
UIBezierPath(roundedRect: clippingRect, cornerRadius: borderRadius).addClip()
if isInline {
let radius = min(borderRadius, min(clippingRect.width, clippingRect.height) / 2)
context.cgContext.addPath(
CGPath(roundedRect: clippingRect, cornerWidth: radius, cornerHeight: radius, transform: nil)
)
context.cgContext.clip()
} else {
UIBezierPath(roundedRect: clippingRect, cornerRadius: borderRadius).addClip()
}
}
image.draw(in: drawingRect)
}
Expand Down
Loading
Loading