diff --git a/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png b/.maestro/androidExample/enrichedMarkdownText/screenshots/android/inline_image_display.png index 87cc3edfe..5b4a230b9 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/ios/inline_image_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/inline_image_display.png index c31f896fd..89dc19550 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/inline_image_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/inline_image_display.png differ diff --git a/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/inline_image_display.png b/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/inline_image_display.png index 5a7edf68a..1513214ac 100644 Binary files a/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/inline_image_display.png and b/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/inline_image_display.png differ diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/InlineImage.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/InlineImage.stories.tsx index 3c514a1e4..70236a5dc 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/InlineImage.stories.tsx +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/InlineImage.stories.tsx @@ -46,3 +46,33 @@ export const Default: TextStory = { ); }, }; + +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 = { + args: { + markdown: NON_SQUARE_MARKDOWN, + ...inlineImageStyledDefaults, + size: 48, + }, + argTypes, + render: (args) => { + const { controls, rest } = splitStyleControls( + args, + inlineImageStyledDefaults + ); + return ( + + ); + }, +}; diff --git a/docs-md/ELEMENTS_STRUCTURE.md b/docs-md/ELEMENTS_STRUCTURE.md index 1aa8e49cd..fdd4d9926 100644 --- a/docs-md/ELEMENTS_STRUCTURE.md +++ b/docs-md/ELEMENTS_STRUCTURE.md @@ -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. diff --git a/docs-md/STYLES.md b/docs-md/STYLES.md index 76d296887..522c14dda 100644 --- a/docs-md/STYLES.md +++ b/docs-md/STYLES.md @@ -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 diff --git a/docs/docs/android/api-reference/element-structure.md b/docs/docs/android/api-reference/element-structure.md index dd9ddeee6..5ca56cb63 100644 --- a/docs/docs/android/api-reference/element-structure.md +++ b/docs/docs/android/api-reference/element-structure.md @@ -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. diff --git a/docs/docs/android/api-reference/style-properties.md b/docs/docs/android/api-reference/style-properties.md index a2294b9ee..0e971133e 100644 --- a/docs/docs/android/api-reference/style-properties.md +++ b/docs/docs/android/api-reference/style-properties.md @@ -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 { diff --git a/docs/docs/ios/api-reference/element-structure.md b/docs/docs/ios/api-reference/element-structure.md index d5692baa3..f46a0b87a 100644 --- a/docs/docs/ios/api-reference/element-structure.md +++ b/docs/docs/ios/api-reference/element-structure.md @@ -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. diff --git a/docs/docs/ios/api-reference/style-properties.md b/docs/docs/ios/api-reference/style-properties.md index 1fdbfb2bc..9e09ee65d 100644 --- a/docs/docs/ios/api-reference/style-properties.md +++ b/docs/docs/ios/api-reference/style-properties.md @@ -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 { diff --git a/docs/docs/react-native/api-reference/element-structure.md b/docs/docs/react-native/api-reference/element-structure.md index 26172c446..cfbae6ef2 100644 --- a/docs/docs/react-native/api-reference/element-structure.md +++ b/docs/docs/react-native/api-reference/element-structure.md @@ -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. diff --git a/docs/docs/react-native/api-reference/style-properties.md b/docs/docs/react-native/api-reference/style-properties.md index fe735495f..e8dec86d8 100644 --- a/docs/docs/react-native/api-reference/style-properties.md +++ b/docs/docs/react-native/api-reference/style-properties.md @@ -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 | diff --git a/docs/src/examples/react-native/api-reference/style-properties/InlineImage.tsx b/docs/src/examples/react-native/api-reference/style-properties/InlineImage.tsx index eed09b201..d12a948d2 100644 --- a/docs/src/examples/react-native/api-reference/style-properties/InlineImage.tsx +++ b/docs/src/examples/react-native/api-reference/style-properties/InlineImage.tsx @@ -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), 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 dfd3545f6..e237ca49e 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 @@ -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 diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ImageCornerRadiusTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ImageCornerRadiusTest.kt new file mode 100644 index 000000000..653b22a65 --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ImageCornerRadiusTest.kt @@ -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 + } +} 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 index 34dd0237c..280de36d0 100644 --- 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 @@ -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) @@ -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) } } diff --git a/packages/enriched-markdown-ios/README.md b/packages/enriched-markdown-ios/README.md index 971323d70..93c3b3dd7 100644 --- a/packages/enriched-markdown-ios/README.md +++ b/packages/enriched-markdown-ios/README.md @@ -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 diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Attachments/MarkdownImageAttachment.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Attachments/MarkdownImageAttachment.swift index 36746be39..e28d27bcd 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Attachments/MarkdownImageAttachment.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Attachments/MarkdownImageAttachment.swift @@ -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. @@ -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)" @@ -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 @@ -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?, @@ -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) } diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift index 6c93f17c6..4d30b3973 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift @@ -100,20 +100,36 @@ enum ParagraphStyleHelpers { guard maximumLineHeight > 0 else { return } output.enumerateAttribute(.font, in: range) { value, subrange, _ in - guard let font = value as? UIFont else { return } - guard output.attribute(.baselineOffset, at: subrange.location, effectiveRange: nil) == nil else { - return - } - - let naturalHeight = typographicLineHeight(for: font) - guard naturalHeight > 0, maximumLineHeight >= naturalHeight else { return } + guard let font = value as? UIFont, + !isInlineImage(output, at: subrange.location), + output.attribute(.baselineOffset, at: subrange.location, effectiveRange: nil) == nil, + let offset = baselineOffset(centring: font, in: maximumLineHeight) + else { return } + output.addAttribute(.baselineOffset, value: offset, range: subrange) + } - let leading = maximumLineHeight - naturalHeight - let baselineOffset = ceil(leading / 2) - output.addAttribute(.baselineOffset, value: baselineOffset, range: subrange) + // Inline images take the same offset through their bounds: TextKit moves an attachment by + // `.baselineOffset` without growing the line for it. The first block to set it wins. + output.enumerateAttribute(.attachment, in: range) { value, _, _ in + guard let image = value as? MarkdownImageAttachment, image.isInline, + image.lineHeightOffset == nil, + let font = image.textFont, + let offset = baselineOffset(centring: font, in: maximumLineHeight) + else { return } + image.lineHeightOffset = offset } } + private static func isInlineImage(_ output: NSAttributedString, at index: Int) -> Bool { + (output.attribute(.attachment, at: index, effectiveRange: nil) as? MarkdownImageAttachment)?.isInline == true + } + + private static func baselineOffset(centring font: UIFont, in maximumLineHeight: CGFloat) -> CGFloat? { + let naturalHeight = typographicLineHeight(for: font) + guard naturalHeight > 0, maximumLineHeight >= naturalHeight else { return nil } + return ceil((maximumLineHeight - naturalHeight) / 2) + } + static func applyBlockLineHeight( to output: NSMutableAttributedString, range: NSRange, diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ImageRenderer.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ImageRenderer.swift index 5ab77d677..2810a06c9 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ImageRenderer.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ImageRenderer.swift @@ -23,6 +23,13 @@ final class ImageRenderer: NodeRenderer { ) var attributes: [NSAttributedString.Key: Any] = [.attachment: attachment] + + // Without a font TextKit uses Helvetica 12, whose descent would grow the line. + if isInline { + let textFont = context.getTextAttributes()[.font] as? UIFont + attachment.textFont = textFont + attributes[.font] = (textFont ?? UIFont.systemFont(ofSize: UIFont.systemFontSize)).withSize(1) + } SourceOffsetAnnotator.tagSourceRange(in: &attributes, of: node) output.append(NSAttributedString(string: "\u{FFFC}", attributes: attributes)) } diff --git a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/InlineImagePlacementTests.swift b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/InlineImagePlacementTests.swift new file mode 100644 index 000000000..89d0fe667 --- /dev/null +++ b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/InlineImagePlacementTests.swift @@ -0,0 +1,181 @@ +import UIKit +import XCTest +@testable import EnrichedMarkdown + +/// Inline images fit inside their square (contain) and centre on the text's cap height. +@MainActor +final class InlineImagePlacementTests: XCTestCase { + func testAnInlineImageIsCentredOnTheCapHeight() throws { + let cases: [(markdown: String, lineHeight: CGFloat?)] = [ + ("HH {image} HH", nil), + ("# HH {image} HH", nil), + ("**HH {image} HH**", nil), + ("HH {image} HH", 48), + ("> HH {image} HH", 48) + ] + for (markdown, lineHeight) in cases { + let label = "\(markdown), line height \(String(describing: lineHeight))" + let rows = try paintedRows(try textView(markdown, config: styleConfig(size: 30, lineHeight: lineHeight))) + XCTAssertEqual(Double(rows.image.count), 30 * scale, accuracy: 1, label) + XCTAssertEqual(center(of: rows.image), center(of: rows.capitals), accuracy: 1, label) + } + } + + func testAnInlineImageIsCentredWithoutATextContainer() throws { + let text = try render("HH {image} HH", config: styleConfig(size: 30, lineHeight: 48)) + let image = try XCTUnwrap( + text.attribute(.attachment, at: try imageIndex(in: text), effectiveRange: nil) as? MarkdownImageAttachment + ) + let font = try XCTUnwrap(text.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) + let lift = text.attribute(.baselineOffset, at: 0, effectiveRange: nil) as? CGFloat ?? 0 + + let bounds = image.attachmentBounds(for: nil, proposedLineFragment: .zero, glyphPosition: .zero, characterIndex: 0) + XCTAssertEqual(bounds.midY, lift + font.capHeight / 2, accuracy: 0.01) + } + + func testASmallInlineImageKeepsTheLineHeight() throws { + let config = styleConfig(size: 4, lineHeight: 0, textSize: 10) + let withImage = try textView("HH {image} HH", config: config) + let withText = try textView("HH H HH", config: config) + XCTAssertEqual(height(of: withImage, width: 300), height(of: withText, width: 300), accuracy: 0.5) + } + + func testAWideInlineImageFitsInsideItsSquare() throws { + let downloader = DeferredImageDownloader() + let attachment = MarkdownImageAttachment.attachment( + for: "https://example.com/\(#function).png", + config: styleConfig(size: 30), + isInline: true, + altText: "", + downloader: downloader + ) + let placeholder = attachment.image + downloader.complete(with: makeImage(width: 64, height: 32)) + try waitUntil("the image is processed") { attachment.image !== placeholder } + + let square = try XCTUnwrap(attachment.image?.cgImage) + let pixels = Pixels(square.width, square.height) { $0.draw(square, in: CGRect(x: 0, y: 0, width: $1, height: $2)) } + let red = pixels.points(where: Pixels.isRed) + let rows = try XCTUnwrap(red.map(\.y).min())...(try XCTUnwrap(red.map(\.y).max())) + + XCTAssertEqual(square.width, square.height, "the square") + XCTAssertEqual(Double(Set(red.map(\.x)).count), Double(square.width), accuracy: 1, "full width") + XCTAssertEqual(Double(rows.count), Double(square.height) / 2, accuracy: 1, "half the height") + XCTAssertEqual(center(of: rows), Double(square.height) / 2, accuracy: 1, "centred") + } + + // MARK: - Helpers + + private let scale = 2.0 + + private func styleConfig(size: CGFloat, lineHeight: CGFloat? = nil, textSize: CGFloat? = nil) -> MarkdownStyleConfiguration { + var paragraph = textSize.map { Paragraph().font(size: $0) } ?? Paragraph() + paragraph.lineHeight = lineHeight + return MarkdownStyleConfiguration.resolve( + layers: [.default, MarkdownTheme { + paragraph + InlineImage().size(size) + Blockquote().foregroundStyle(.black) + }], + traitCollection: .current + ) + } + + /// `markdown` rendered with a red image for each `{image}`. + private func render(_ markdown: String, config: MarkdownStyleConfiguration) throws -> NSAttributedString { + let png = try XCTUnwrap(makeImage(width: 32, height: 32).pngData()) + let image = "![red](data:image/png;base64,\(png.base64EncodedString()))" + return MarkdownRenderer.render(markdown.replacingOccurrences(of: "{image}", with: image), config: config) + } + + private func textView(_ markdown: String, config: MarkdownStyleConfiguration) throws -> MarkdownTextView { + let textView = MarkdownTextView() + textView.styleConfig = config + textView.backgroundColor = .white + textView.setMarkdownAttributedText(try render(markdown, config: config)) + return textView + } + + private func imageIndex(in text: NSAttributedString) throws -> Int { + var index: Int? + text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, range, stop in + guard value is MarkdownImageAttachment else { return } + index = range.location + stop.pointee = true + } + return try XCTUnwrap(index, "no image") + } + + private func paintedRows(_ textView: MarkdownTextView) throws -> (image: ClosedRange, capitals: ClosedRange) { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + window.addSubview(textView) + window.makeKeyAndVisible() + defer { window.isHidden = true } + + var pixels = Pixels(0, 0) { _, _, _ in } + try waitUntil("the image draws") { + // A whole-point height keeps the bottom row of pixels whole. + textView.frame = CGRect(x: 0, y: 0, width: 300, height: ceil(height(of: textView, width: 300))) + textView.layoutIfNeeded() + pixels = Pixels(Int(300 * scale), Int(textView.frame.height * scale)) { context, _, height in + context.translateBy(x: 0, y: height) + context.scaleBy(x: scale, y: -scale) + textView.layer.render(in: context) + } + return !pixels.points(where: Pixels.isRed).isEmpty + } + let red = pixels.points(where: Pixels.isRed) + let imageEnd = try XCTUnwrap(red.map(\.x).max()) + let capitals = pixels.points(where: Pixels.isDark).filter { $0.x > imageEnd + 2 }.map(\.y) + return ( + try XCTUnwrap(red.map(\.y).min())...(try XCTUnwrap(red.map(\.y).max())), + try XCTUnwrap(capitals.min())...(try XCTUnwrap(capitals.max())) + ) + } + + private func center(of rows: ClosedRange) -> Double { + Double(rows.lowerBound + rows.upperBound + 1) / 2 + } + + /// Runs the main run loop until `condition` holds, failing after `timeout`. + private func waitUntil(_ description: String, timeout: TimeInterval = 5, _ condition: () throws -> Bool) throws { + let deadline = Date(timeIntervalSinceNow: timeout) + while try !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting until \(description)") } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + } + } +} + +/// RGBA pixels drawn into a bitmap, top row first. +private struct Pixels { + private var bytes: [UInt8] + private let width: Int + + init(_ width: Int, _ height: Int, draw: (CGContext, CGFloat, CGFloat) -> Void) { + self.width = width + bytes = [UInt8](repeating: 0, count: width * height * 4) + bytes.withUnsafeMutableBytes { buffer in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + draw(context, CGFloat(width), CGFloat(height)) + } + } + + static func isRed(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> Bool { + red > 200 && green < 80 && blue < 80 + } + + static func isDark(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> Bool { + max(red, green, blue) < 90 + } + + func points(where matches: (UInt8, UInt8, UInt8) -> Bool) -> [(x: Int, y: Int)] { + stride(from: 0, to: bytes.count, by: 4).compactMap { offset in + guard bytes[offset + 3] > 200, matches(bytes[offset], bytes[offset + 1], bytes[offset + 2]) else { return nil } + return (x: offset / 4 % width, y: offset / 4 / width) + } + } +} diff --git a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/MarkdownImageAttachmentTests.swift b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/MarkdownImageAttachmentTests.swift index 849eb2a39..b4e855f95 100644 --- a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/MarkdownImageAttachmentTests.swift +++ b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/MarkdownImageAttachmentTests.swift @@ -244,7 +244,7 @@ final class MarkdownImageAttachmentTests: XCTestCase { XCTAssertEqual(explicit.contentMode, .fit) } - func testInlineImagesAlwaysFillTheirSquare() { + func testInlineImagesFitInsideTheirSquare() { let downloader = DeferredImageDownloader() let attachment = MarkdownImageAttachment.attachment( for: "https://example.com/\(#function).png", @@ -254,7 +254,7 @@ final class MarkdownImageAttachmentTests: XCTestCase { downloader: downloader ) - XCTAssertEqual(attachment.contentMode, .stretch) + XCTAssertEqual(attachment.contentMode, .fit) } func testLoadArrivingOffTheMainThreadIsAppliedOnMain() { diff --git a/packages/react-native-enriched-markdown/__tests__/ios/ENRMInlineImagePlacementTests.mm b/packages/react-native-enriched-markdown/__tests__/ios/ENRMInlineImagePlacementTests.mm new file mode 100644 index 000000000..dd4d85d08 --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/ios/ENRMInlineImagePlacementTests.mm @@ -0,0 +1,161 @@ +#import "../../ios/attachments/ENRMImageAttachment.h" +#import "../../ios/parser/ENRMMarkdownParser.h" +#import "../../ios/styles/StyleConfig.h" +#import "../../ios/utils/ENRMTextRenderer.h" +#import +#import + +/// Inline images fit inside their square (contain) and centre on the text's cap height. +@interface ENRMInlineImagePlacementTests : XCTestCase +@property (nonatomic, strong) NSMutableArray *temporaryFiles; +@end + +@implementation ENRMInlineImagePlacementTests + +- (void)setUp +{ + [super setUp]; + self.temporaryFiles = [NSMutableArray array]; +} + +- (void)tearDown +{ + for (NSString *path in self.temporaryFiles) { + [[NSFileManager defaultManager] removeItemAtPath:path error:NULL]; + } + [super tearDown]; +} + +- (NSTextStorage *)render:(NSString *)markdown + imageSize:(CGSize)imageSize + strongFontFamily:(NSString *)strongFontFamily + image:(ENRMImageAttachment *__strong *)image + index:(NSUInteger *)index +{ + NSData *png = [[[UIGraphicsImageRenderer alloc] initWithSize:imageSize] + PNGDataWithActions:^(UIGraphicsImageRendererContext *context) { + [[UIColor redColor] setFill]; + [context fillRect:CGRectMake(0, 0, imageSize.width, imageSize.height)]; + }]; + NSString *path = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"enrm-inline-%@.png", [NSUUID UUID].UUIDString]]; + [png writeToFile:path atomically:YES]; + [self.temporaryFiles addObject:path]; + + StyleConfig *config = [[StyleConfig alloc] init]; + [config setInlineImageSize:30]; + [config setParagraphFontSize:18]; + [config setH1FontSize:30]; + if (strongFontFamily) { + [config setStrongFontFamily:strongFontFamily]; + } + NSString *imageMarkdown = [NSString stringWithFormat:@"![red](%@)", [NSURL fileURLWithPath:path].absoluteString]; + MarkdownASTNode *ast = [[[ENRMMarkdownParser alloc] init] + parseMarkdown:[markdown stringByReplacingOccurrencesOfString:@"{image}" withString:imageMarkdown] + flags:[ENRMMd4cFlags defaultFlags] + isGFM:YES]; + NSAttributedString *text = + ENRMRenderASTNodes(ast.children, config, NO, YES, 0, NSLineBreakStrategyStandard).attributedText; + + [text enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, text.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[ENRMImageAttachment class]]) { + *image = value; + *index = range.location; + *stop = YES; + } + }]; + XCTAssertNotNil(*image, @"%@: no image", markdown); + return [[NSTextStorage alloc] initWithAttributedString:text]; +} + +- (void)testInlineImageIsCentredOnTheCapHeight +{ + for (NSArray *item in @[ @[ @"HH {image} HH", @"" ], @[ @"# HH {image} HH", @"" ] ]) { + ENRMImageAttachment *image = nil; + NSUInteger index = 0; + NSTextStorage *storage = [self render:item[0] + imageSize:CGSizeMake(32, 32) + strongFontFamily:item[1].length > 0 ? item[1] : nil + image:&image + index:&index]; + + NSTextContainer *container = [[NSTextContainer alloc] initWithSize:CGSizeMake(300, CGFLOAT_MAX)]; + container.lineFragmentPadding = 0; + NSLayoutManager *manager = [[NSLayoutManager alloc] init]; + [manager addTextContainer:container]; + [storage addLayoutManager:manager]; + [manager ensureLayoutForTextContainer:container]; + + // TextKit places a text glyph on the baseline and an attachment glyph at the image's bottom edge. + NSUInteger glyph = [manager glyphIndexForCharacterAtIndex:index]; + NSUInteger textGlyph = [manager glyphIndexForCharacterAtIndex:index + 2]; + CGFloat lineTop = CGRectGetMinY([manager lineFragmentRectForGlyphAtIndex:glyph effectiveRange:NULL]); + CGFloat imageBottom = lineTop + [manager locationForGlyphAtIndex:glyph].y; + CGFloat baseline = lineTop + [manager locationForGlyphAtIndex:textGlyph].y; + CGFloat imageHeight = [manager attachmentSizeForGlyphAtIndex:glyph].height; + UIFont *textFont = [storage attribute:NSFontAttributeName atIndex:index + 2 effectiveRange:NULL]; + + XCTAssertEqualWithAccuracy(imageHeight, 30, 0.5, @"%@", item[0]); + XCTAssertEqualWithAccuracy(imageBottom - imageHeight / 2, baseline - textFont.capHeight / 2, 0.5, @"%@ %@", item[0], + item[1]); + } +} + +- (void)testInlineImageTakesTheFontOfStrongText +{ + ENRMImageAttachment *image = nil; + NSUInteger index = 0; + NSTextStorage *storage = [self render:@"**HH {image} HH**" + imageSize:CGSizeMake(32, 32) + strongFontFamily:@"Georgia" + image:&image + index:&index]; + UIFont *textFont = [storage attribute:NSFontAttributeName atIndex:index + 2 effectiveRange:NULL]; + + XCTAssertEqualObjects(textFont.familyName, @"Georgia"); + XCTAssertEqualObjects(image.textFont, textFont); +} + +- (void)testWideInlineImageFitsInsideItsSquare +{ + ENRMImageAttachment *image = nil; + NSUInteger index = 0; + [self render:@"HH {image} HH" imageSize:CGSizeMake(64, 32) strongFontFamily:nil image:&image index:&index]; + XCTestExpectation *loaded = [self expectationWithDescription:@"the image loads"]; + loaded.assertForOverFulfill = NO; + image.onImageLoaded = ^{ [loaded fulfill]; }; + [self waitForExpectations:@[ loaded ] timeout:5]; + image.onImageLoaded = nil; + + CGImageRef square = ((UIImage *)[image valueForKey:@"loadedImage"]).CGImage; + size_t w = CGImageGetWidth(square), h = CGImageGetHeight(square); + NSMutableData *pixels = [NSMutableData dataWithLength:w * h * 4]; + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate(pixels.mutableBytes, w, h, 8, w * 4, space, + kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); + CGContextDrawImage(context, CGRectMake(0, 0, w, h), square); + CGContextRelease(context); + CGColorSpaceRelease(space); + + const uint8_t *bytes = (const uint8_t *)pixels.bytes; + NSInteger top = NSIntegerMax, bottom = -1, left = NSIntegerMax, right = -1; + for (size_t y = 0; y < h; y++) { + for (size_t x = 0; x < w; x++) { + const uint8_t *p = bytes + (y * w + x) * 4; + if (p[0] > 200 && p[1] < 80 && p[2] < 80) { + top = MIN(top, (NSInteger)y), bottom = MAX(bottom, (NSInteger)y); + left = MIN(left, (NSInteger)x), right = MAX(right, (NSInteger)x); + } + } + } + + XCTAssertEqual(w, h, @"the square"); + XCTAssertEqualWithAccuracy(right - left + 1, w, 1, @"full width"); + XCTAssertEqualWithAccuracy(bottom - top + 1, h / 2.0, 1, @"half the height"); + XCTAssertEqualWithAccuracy((top + bottom + 1) / 2.0, h / 2.0, 1, @"centred"); +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.h b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.h index 7d6a46b1d..b403ae631 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.h +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.h @@ -37,6 +37,7 @@ NS_ASSUME_NONNULL_BEGIN /// when the override says to show the placeholder only). @property (nonatomic, readonly) NSString *requestURL; @property (nonatomic, readonly) BOOL isInline; +@property (nonatomic, strong, nullable) UIFont *textFont; /** * Invoked on the main thread whenever the image finishes loading/processing. @@ -58,4 +59,6 @@ NS_ASSUME_NONNULL_BEGIN @end +FOUNDATION_EXPORT void ENRMImageAttachmentsAdoptTextFonts(NSAttributedString *text); + NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m index 217f692dc..d480dc220 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m @@ -7,6 +7,8 @@ #define CACHE_KEY_PROCESSED(url, w, h, r, m) [NSString stringWithFormat:@"%@_w%.1f_h%.1f_r%.1f_m%@", url, w, h, r, m] +static NSString *const kInlineDrawingMode = @"contain"; + static NSCache *_originalImageCache; static NSCache *_processedImageCache; @@ -150,17 +152,9 @@ - (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer return CGRectMake(0, 0, width, height); } - UIFont *appliedFont = nil; - NSLayoutManager *layoutManager = textContainer.layoutManager; - NSTextStorage *textStorage = layoutManager.textStorage; - - if (textStorage && characterIndex < textStorage.length) { - appliedFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL]; - } - CGFloat verticalOffset; - if (appliedFont) { - verticalOffset = (appliedFont.capHeight - height) / 2.0; + if (self.textFont) { + verticalOffset = (self.textFont.capHeight - height) / 2.0; } else { verticalOffset = (lineFragment.size.height - height) / 2.0; } @@ -213,8 +207,9 @@ - (void)processAndApplyImage:(RCTUIImage *)image withTargetWidth:(CGFloat)target CGFloat boxHeight = self.isInline ? self.cachedHeight : [self resolvedBoxHeightForWidth:targetWidth]; + NSString *drawingMode = self.isInline ? @"inline" : self.cachedResizeMode; NSString *processedKey = - CACHE_KEY_PROCESSED(self.cacheKey, targetWidth, boxHeight, self.cachedBorderRadius, self.cachedResizeMode); + CACHE_KEY_PROCESSED(self.cacheKey, targetWidth, boxHeight, self.cachedBorderRadius, drawingMode); if ([processedKey isEqualToString:self.lastProcessedKey]) return; @@ -270,18 +265,11 @@ - (RCTUIImage *)createScaledImage:(RCTUIImage *)image BOOL legacy = [self isLegacyBlockSizing]; CGRect drawingRect; - if (self.isInline || legacy) { - CGFloat drawingWidth, drawingHeight; - if (!self.isInline) { - CGFloat aspectRatioScale = targetWidth / sourceWidth; - drawingWidth = targetWidth; - drawingHeight = sourceHeight * aspectRatioScale; - } else { - drawingWidth = targetWidth; - drawingHeight = targetHeight; - } - drawingRect = CGRectMake((targetWidth - drawingWidth) / 2.0, (targetHeight - drawingHeight) / 2.0, drawingWidth, - drawingHeight); + if (self.isInline) { + drawingRect = [self drawingRectForResizeMode:kInlineDrawingMode source:source box:box]; + } else if (legacy) { + CGFloat drawingHeight = sourceHeight * (targetWidth / sourceWidth); + drawingRect = CGRectMake(0, (targetHeight - drawingHeight) / 2.0, targetWidth, drawingHeight); } else { drawingRect = [self drawingRectForResizeMode:self.cachedResizeMode source:source box:box]; } @@ -291,8 +279,16 @@ - (RCTUIImage *)createScaledImage:(RCTUIImage *)image return [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *context) { if (radius > 0) { CGRect clipRect = CGRectIntersection(CGRectMake(0, 0, targetWidth, targetHeight), drawingRect); - UIBezierPath *path = UIBezierPathWithRoundedRect(clipRect, radius); - [path addClip]; + if (self.isInline) { + CGFloat cornerRadius = MIN(radius, MIN(clipRect.size.width, clipRect.size.height) / 2.0); + CGPathRef path = CGPathCreateWithRoundedRect(clipRect, cornerRadius, cornerRadius, NULL); + CGContextAddPath(context.CGContext, path); + CGContextClip(context.CGContext); + CGPathRelease(path); + } else { + UIBezierPath *path = UIBezierPathWithRoundedRect(clipRect, radius); + [path addClip]; + } } [image drawInRect:drawingRect]; }]; @@ -410,3 +406,19 @@ - (NSRange)findAttachmentRangeInText:(NSAttributedString *)attributedString } @end + +void ENRMImageAttachmentsAdoptTextFonts(NSAttributedString *text) +{ + [text enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, text.length) + options:0 + usingBlock:^(id value, NSRange range, __unused BOOL *stop) { + if (![value isKindOfClass:[ENRMImageAttachment class]] || !((ENRMImageAttachment *)value).isInline) { + return; + } + UIFont *font = [text attribute:NSFontAttributeName atIndex:range.location effectiveRange:NULL]; + if (font) { + ((ENRMImageAttachment *)value).textFont = font; + } + }]; +} diff --git a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m index 737865d74..7ed45301f 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m @@ -2,6 +2,7 @@ #import "BlockquoteBorder.h" #import "CodeBlockBackground.h" #import "ENRMBlockquoteTextRenderer.h" +#import "ENRMImageAttachment.h" #import "ENRMLinkPillText.h" #import "LastElementUtils.h" #import "MarkdownASTNode.h" @@ -66,6 +67,7 @@ - (NSMutableAttributedString *)renderNodes:(NSArray *)nodes // 4. Let the decorator post-process the finished string (e.g. stamp the quote's line height). [block postProcess:output]; ENRMLinkPillsAdoptPlaceholderFonts(output); + ENRMImageAttachmentsAdoptTextFonts(output); // 5. Cleanup global state to prevent side effects in subsequent renders. [context clearBlockStyle]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m index 8d4e1a083..59e526841 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m @@ -30,6 +30,10 @@ - (void)renderNodeContent:(MarkdownASTNode *)node NSUInteger startIndex = output.length; + if (isInline) { + attachment.textFont = [context getTextAttributes][NSFontAttributeName]; + } + NSAttributedString *imageString = [NSAttributedString attributedStringWithAttachment:attachment]; [output appendAttributedString:imageString]; diff --git a/packages/react-native-enriched-markdown/src/web/__tests__/inlineImageStyle.test.ts b/packages/react-native-enriched-markdown/src/web/__tests__/inlineImageStyle.test.ts new file mode 100644 index 000000000..b40423376 --- /dev/null +++ b/packages/react-native-enriched-markdown/src/web/__tests__/inlineImageStyle.test.ts @@ -0,0 +1,35 @@ +import { normalizeMarkdownStyle } from '../../normalizeMarkdownStyle.web'; +import type { buildStyles as BuildStyles } from '../styles'; + +function inlineImageStyle(supportsCap: boolean) { + const previous = (globalThis as { CSS?: unknown }).CSS; + (globalThis as { CSS?: unknown }).CSS = { supports: () => supportsCap }; + try { + let buildStyles: typeof BuildStyles | undefined; + jest.isolateModules(() => { + buildStyles = require('../styles').buildStyles; + }); + return buildStyles!(normalizeMarkdownStyle({ inlineImage: { size: 48 } })) + .inlineImage; + } finally { + (globalThis as { CSS?: unknown }).CSS = previous; + } +} + +describe('inline image style', () => { + it('fits the image inside its square (#1083)', () => { + expect(inlineImageStyle(true)).toMatchObject({ + width: 48, + height: 48, + objectFit: 'contain', + }); + }); + + it('centres on the cap height', () => { + expect(inlineImageStyle(true).verticalAlign).toBe('calc(0.5cap - 24px)'); + }); + + it('centres on the middle where `cap` is unsupported', () => { + expect(inlineImageStyle(false).verticalAlign).toBe('middle'); + }); +}); diff --git a/packages/react-native-enriched-markdown/src/web/styles.ts b/packages/react-native-enriched-markdown/src/web/styles.ts index 7c3ac81ad..f1d51a332 100644 --- a/packages/react-native-enriched-markdown/src/web/styles.ts +++ b/packages/react-native-enriched-markdown/src/web/styles.ts @@ -259,12 +259,23 @@ function videoStyle(style: MarkdownStyleInternal): CSSProperties { return base; } +// Browsers without the `cap` would drop the whole declaration. +const SUPPORTS_CAP_UNIT = + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('vertical-align', '1cap'); + function inlineImageStyle(style: MarkdownStyleInternal): CSSProperties { const size = style.inlineImage.size; return { width: size, height: size, - verticalAlign: 'middle', + objectFit: 'contain', + // Centre on the cap height: lift the bottom edge to half the cap height + // minus half the image. Without `cap`, the middle. + verticalAlign: SUPPORTS_CAP_UNIT + ? `calc(0.5cap - ${size / 2}px)` + : 'middle', display: 'inline', }; }