diff --git a/packages/ios-enriched-markdown/README.md b/packages/ios-enriched-markdown/README.md index 47d79c23..97e92801 100644 --- a/packages/ios-enriched-markdown/README.md +++ b/packages/ios-enriched-markdown/README.md @@ -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` @@ -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` @@ -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 diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Accessibility/MarkdownAccessibilityElementBuilder.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Accessibility/MarkdownAccessibilityElementBuilder.swift index b0d60388..8d830fc3 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Accessibility/MarkdownAccessibilityElementBuilder.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Accessibility/MarkdownAccessibilityElementBuilder.swift @@ -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 @@ -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 @@ -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. diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift index 92d152d4..928629d6 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/ParagraphStyleHelpers.swift @@ -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) diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift index 0dae4c2f..98607624 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift @@ -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 diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageCacheKey.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageCacheKey.swift index 64f94d0f..c93d77e2 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageCacheKey.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageCacheKey.swift @@ -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 diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageDecoder.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageDecoder.swift index 82d3ea55..0827f47a 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageDecoder.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/ImageDecoder.swift @@ -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( diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/LocalImageLoader.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/LocalImageLoader.swift index e662a62e..99aded5c 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/LocalImageLoader.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Utils/LocalImageLoader.swift @@ -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"] @@ -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: "_") } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+Selection.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+Selection.swift index 704e472a..98e2e14e 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+Selection.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+Selection.swift @@ -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) } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift index bbd81479..b82417ca 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift @@ -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 diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ImageCacheKeyTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ImageCacheKeyTests.swift index 3e96987d..989c4049 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ImageCacheKeyTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/ImageCacheKeyTests.swift @@ -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" diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/LocalImageLoaderTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/LocalImageLoaderTests.swift index 39a154c5..f544fda2 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/LocalImageLoaderTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/LocalImageLoaderTests.swift @@ -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")