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
116 changes: 112 additions & 4 deletions packages/ios-enriched-markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,34 @@ Element-specific modifiers include:

```swift
public struct EnrichedMarkdownText: View {
public init(_ markdown: String)
public init(_ markdown: String, flags: Md4cFlags = .commonMark)
}
```

| Parameter | Description |
|-----------|-------------|
| `markdown` | Markdown source string |
| `flags` | Optional parser extensions (see `Md4cFlags`) |

Style and link handling come from the environment (`.markdownTheme`, `.onLinkPress`), not from initializer parameters.
Style and interaction handling come from the environment (`.markdownTheme`, `.onLinkPress`, and the other modifiers below), not from initializer parameters.

### `Md4cFlags`

```swift
public struct Md4cFlags: Equatable, Sendable {
public var underline: Bool // __text__ renders underlined instead of bold
public var hardSoftBreaks: Bool // single newlines become visible line breaks
public var permissiveAutolinks: Bool // bare URLs become links (default true)
public var latexMath: Bool
public var superscript: Bool
public var subscript: Bool
public var highlight: Bool

public static let commonMark: Md4cFlags
}
```

`underline`, `hardSoftBreaks`, and `permissiveAutolinks` affect rendering. The remaining flags gate parsing only — their content currently renders as plain text.

### `.markdownTheme`

Expand All @@ -193,15 +212,59 @@ public struct MarkdownTheme: Sendable {
}
```

### `.onLinkPress`
### `.onLinkPress` / `.onLinkLongPress`

```swift
extension View {
func onLinkPress(_ action: @escaping (URL) -> Void) -> some View
func onLinkLongPress(_ action: @escaping (URL) -> Void) -> some View
}
```

`onLinkPress` is called when a link is tapped. `onLinkLongPress` is called when a link is long-pressed, replacing the system link menu; without it, a long-press behaves like a press when `onLinkPress` is set. Scope either to a single view or a larger subtree.

### `.markdownSelectable` / `.markdownSelectionColor`

```swift
extension View {
func markdownSelectable(_ isSelectable: Bool) -> some View // default true
func markdownSelectionColor(_ color: Color?) -> some View // default nil = system tint
}
```

`markdownSelectable(false)` disables text selection while links stay tappable. `markdownSelectionColor` tints the selection highlight, handles, and caret (UIKit derives all three from one tint).

### `.markdownSelectionMenu`

```swift
public struct MarkdownSelectionMenuConfig: Equatable, Sendable {
public init(
copyAsMarkdown: Bool = true,
copyImageUrl: Bool = true,
copyAsMarkdownLabel: String = "Copy as Markdown"
)
}

extension View {
func markdownSelectionMenu(_ config: MarkdownSelectionMenuConfig) -> some View
}
```

Called when a link inside `EnrichedMarkdownText` is tapped. Scope it to a single view or a larger subtree.
Configures the custom items added to the text-selection edit menu (iOS 16+; earlier versions keep the stock menu):

- **Copy as Markdown** puts the selection on the clipboard as markdown. A selection covering the whole document returns the original source verbatim; partial selections are reconstructed from the rendered text.
- **Copy Image URL** / **Copy N Image URLs** appears when the selection contains images with http(s) URLs.
- **Select All** is provided when the system omits it for non-editable text views.

### `.markdownImageRequestHeaders`

```swift
extension View {
func markdownImageRequestHeaders(_ headers: [String: String]) -> some View
}
```

Custom HTTP headers sent with every markdown image request, e.g. for authenticated CDNs. The same URL fetched with different headers is cached separately.

### `rememberMarkdownTheme`

Expand All @@ -216,11 +279,56 @@ public func rememberMarkdownTheme(

Re-creates a theme when `colorScheme` or `dynamicTypeSize` changes. Call from `View.body` after reading those environment values.

## Copy & clipboard

System **Copy** puts two flavors of the selection on the pasteboard: plain text and styled HTML (`public.html`), so pasting into rich-text targets keeps headings, inline styles, lists, blockquotes, code blocks, links, and images. Plain-text targets receive plain text as usual.

The selection menu additionally offers **Copy as Markdown** and **Copy Image URL(s)** — see `.markdownSelectionMenu` above.

## Image sources

Images load from these sources:

| Source | Example |
|--------|---------|
| `http(s)://` | `![alt](https://example.com/pic.png)` — with `.markdownImageRequestHeaders` applied |
| `file://` | `![alt](file:///path/to/pic.png)` — percent-encoded paths supported |
| Absolute path | `![alt](/path/to/pic.png)` |
| `data:` | `![alt](data:image/png;base64,…)` |
| Bundle resource name | `![alt](logo.png)` — looked up in `Bundle.main` (loose files and asset catalogs), with a normalized fallback (lowercase, `-` → `_`) |

All decodes are downsampled to the screen's pixel width, so large images never decode at full size. Downloads are cached (memory + disk) and deduplicated in flight.

## Accessibility

VoiceOver walks the rendered markdown as individual elements rather than one text blob (iOS 16+):

- Headings announce "heading, level N"
- Links are activatable elements that invoke `.onLinkPress`
- Images read their alt text ("Image" when absent)
- List items announce their position ("bullet point", "list item N", with a "nested" prefix)

Dynamic Type is supported throughout via text styles in the default theme.

## iOS version notes

The package supports iOS 15+, but some features require iOS 16:

| Feature | Minimum iOS |
|---------|-------------|
| Text rendering, themes, links, selection, images | 15 |
| Copy with HTML flavor, `.onLinkLongPress`, `.markdownSelectable`, `.markdownSelectionColor`, `.markdownImageRequestHeaders` | 15 |
| Block decorations (code-block backgrounds, blockquote bars, list markers) | 16 |
| Custom selection-menu items (`.markdownSelectionMenu`) | 16 |
| VoiceOver element tree | 16 |

## Supported Markdown

- Headings (`#`–`######`)
- Paragraphs, line breaks
- **Bold**, *italic*, `inline code`
- ~~Strikethrough~~ (`~~text~~`)
- Underline (`__text__` with `Md4cFlags(underline: true)`)
- Fenced code blocks
- Block quotes
- Ordered and unordered lists
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ struct MarkdownAccessibilityElementSpec: Equatable {
let label: String
/// Trimmed character range used for frame calculation.
let range: NSRange
/// Spoken list context ("bullet point", "list item 2", "nested …"),
/// matching the Android package's TalkBack announcements.
/// Spoken list context ("bullet point", "list item 2", "nested …").
let listAnnouncement: String?
}

/// Segments the rendered attributed string into VoiceOver elements the way
/// Android's `MarkdownAccessibilityHelper` segments its Spanned: split into
/// paragraphs, drop spacer-only paragraphs, and carve heading/link/image
/// runs into their own elements with plain text between them.
/// Segments the rendered attributed string into VoiceOver elements: split
/// into paragraphs, drop spacer-only paragraphs, and carve heading/link/
/// image runs into their own elements with plain text between them.
enum MarkdownAccessibilityElementBuilder {
/// Zero-width space (list marker anchors) and line separator join plain
/// whitespace as "invisible" for trimming; U+FFFC attachment characters
Expand Down Expand Up @@ -143,8 +141,7 @@ enum MarkdownAccessibilityElementBuilder {
case .link:
guard let visible = trimmedRange(of: run.range, in: text) else { return }
label = (text.string as NSString).substring(with: visible)
// Links announce their list context even mid-item (Android:
// requireStart is false for links).
// Links announce their list context even mid-item.
announcement = listAnnouncement(in: text, at: run.range.location, requireStart: false)
case .text:
return
Expand Down Expand Up @@ -210,9 +207,9 @@ enum MarkdownAccessibilityElementBuilder {
)

if requireStart {
// Only the first segment of a list item announces its position
// (Android's requireStart rule): the item's first visible
// character must be at, or just before, this position. The item's
// Only the first segment of a list item announces its position:
// the item's first visible character must be at, or just before,
// this position. The item's
// extent is where BOTH number and depth are constant — number
// alone merges across nesting levels (outer item 1 / inner item
// 1 are adjacent equal values), depth alone merges siblings.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ enum ParagraphStyleHelpers {
output.append(spacer)
}

/// Matches Android `LineHeightSpan` natural height: `(-ascent) + descent`.
/// Natural line height: `(-ascent) + descent`, consistent across platforms.
private static func typographicLineHeight(for font: UIFont) -> CGFloat {
let ctFont = font as CTFont
return CTFontGetAscent(ctFont) + CTFontGetDescent(ctFont)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ final class HeadingRenderer: NodeRenderer {
UIFont.systemFont(ofSize: Self.defaultPointSize(for: level), weight: .regular)
}

/// Matches Android `DefaultStyles` heading sizes and regular (400) weight.
/// Default heading sizes with regular (400) weight, consistent across platforms.
private static func defaultPointSize(for level: Int) -> CGFloat {
switch level {
case 1: return 30
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ enum ImageCacheKey {
/// Returns the URL unchanged when no headers are set; otherwise appends a
/// SHA-256 digest of the sorted header pairs, so the same URL fetched with
/// different headers is cached and deduplicated separately without
/// embedding header values in the key. Matches the Android
/// implementation (`ImageCache.requestKey`) byte for byte.
/// embedding header values in the key. The key format (sorted `key:value`
/// pairs joined with newlines, hashed to hex) is shared across platforms —
/// keep it stable.
static func requestKey(url: String, headers: [String: String]) -> String {
guard !headers.isEmpty else { return url }
let joined = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import UIKit

enum ImageDecoder {
/// Decodes image data downsampled so its longer side is at most
/// `maxPixelSize` (screen pixel width by default), mirroring the Android
/// package's decode-time `inSampleSize` downsampling. Never upscales.
/// `maxPixelSize` (screen pixel width by default), so large images never
/// decode at full size. Never upscales.
/// The result carries the screen scale so point-size math downstream is
/// unchanged, and EXIF orientation is baked in.
static func decodeDownsampled(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import UIKit
import os.log

/// Loads markdown images from non-network sources, mirroring the Android
/// package's `LocalImageLoader`:
/// Loads markdown images from non-network sources:
/// - `file://` URIs (including percent-encoded paths)
/// - absolute file paths
/// - `data:` URIs (base64 payloads)
/// - bare names resolved as bundled image resources, with Android's name
/// normalization (lowercase, `-` → `_`) applied as a fallback so
/// cross-platform markdown resolves on both OSes
/// - bare names resolved as bundled image resources, with a normalized
/// fallback (lowercase, `-` → `_`) so the same markdown resolves across
/// platforms
///
/// Android-only sources (`content://`, `asset://`, `res://`) have no iOS
/// equivalent and return nil. All decodes are downsampled to screen width
/// like the network path; asset-catalog images are the exception (no file
/// URL to decode from) and load through `UIImage(named:)` as-is.
/// Sources with no iOS equivalent (`content://`, `asset://`, `res://`) log
/// and return nil. All decodes are downsampled to screen width like the
/// network path; asset-catalog images are the exception (no file URL to
/// decode from) and load through `UIImage(named:)` as-is.
enum LocalImageLoader {
private static let base64Marker = "base64,"
private static let resourceExtensions = ["png", "jpg", "jpeg", "gif", "heic"]
Expand All @@ -39,8 +38,8 @@ enum LocalImageLoader {
}
}

/// Android resource-name normalization (`ResourceDrawableIdHelper`):
/// lowercase with `-` replaced by `_`.
/// Resource-name normalization: lowercase with `-` replaced by `_`, so
/// the same markdown resolves across platforms.
static func normalizedResourceName(_ name: String) -> String {
name.lowercased().replacingOccurrences(of: "-", with: "_")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ public extension View {
environment(\.markdownSelectable, isSelectable)
}

/// Tint for the selection highlight, handles, and caret. UIKit derives
/// all three from a single tint, so this covers both Android's
/// `selectionColor` and `selectionHandleColor`. `nil` keeps the
/// system tint.
/// Tint for the selection highlight, handles, and caret — UIKit derives
/// all three from a single tint. `nil` keeps the system tint.
func markdownSelectionColor(_ color: Color?) -> some View {
environment(\.markdownSelectionColor, color)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ struct MarkdownTextViewRepresentable: UIViewRepresentable {

// Recent iOS versions stop suggesting Select All for non-editable text
// views, leaving no way to grow a long-press selection to the whole
// document; provide it ourselves when the system didn't (Android's
// selection menu always has it).
// document; provide it ourselves when the system didn't.
// The system shows its own item only when the command is suggested AND
// canPerformAction allows it; recent iOS returns false there for
// non-editable text views, hiding Select All even though the command
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ final class ImageCacheKeyTests: XCTestCase {
XCTAssertTrue(digest.allSatisfy(\.isHexDigit))
}

func testMatchesAndroidDigestFormat() {
// SHA-256 of "Authorization:Bearer token" — pinned so the key stays
// byte-identical to Android's ImageCache.requestKey.
func testDigestFormatIsPinned() {
// SHA-256 of "Authorization:Bearer token" — pinned so the key format
// shared across platforms never drifts.
XCTAssertEqual(
ImageCacheKey.requestKey(url: url, headers: ["Authorization": "Bearer token"]),
url + "|06a97d81903f645b2b8286be8e5251b3ed323a07533a0609bbb87e66d7a40821"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ final class LocalImageLoaderTests: XCTestCase {
XCTAssertNil(LocalImageLoader.load(""))
}

func testResourceNameNormalizationMatchesAndroid() {
func testResourceNameNormalization() {
XCTAssertEqual(LocalImageLoader.normalizedResourceName("My-Logo"), "my_logo")
XCTAssertEqual(LocalImageLoader.normalizedResourceName("SRC-Assets-Logo"), "src_assets_logo")
XCTAssertEqual(LocalImageLoader.normalizedResourceName("already_normal"), "already_normal")
Expand Down
Loading