diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift
new file mode 100644
index 00000000..4eaed09a
--- /dev/null
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift
@@ -0,0 +1,335 @@
+import UIKit
+
+/// Reconstructs markdown source from a rendered `NSAttributedString`.
+///
+/// The renderers strip the original markdown syntax, so a partial selection is
+/// reverse-engineered from the custom `MarkdownAttribute` keys the renderers
+/// leave behind. Block chrome (list markers, blockquote bars, code-block
+/// backgrounds) lives in decoration views rather than the text itself, which is
+/// why prefixes are derived from attributes instead of the string content.
+///
+/// Reconstruction is CommonMark-equivalent, not byte-identical: soft breaks
+/// come back as spaces and heading text loses inline markers. A selection that
+/// covers the whole document therefore returns the original source verbatim.
+enum MarkdownExtractor {
+ /// Markdown for `range`, using `sourceMarkdown` verbatim when the range
+ /// covers the entire rendered document.
+ static func markdown(
+ for range: NSRange,
+ in attributedText: NSAttributedString,
+ sourceMarkdown: String?
+ ) -> String? {
+ guard let clamped = clampedRange(range, in: attributedText) else { return nil }
+
+ if let sourceMarkdown, isFullSelection(clamped, in: attributedText) {
+ return sourceMarkdown
+ }
+ return extractMarkdown(from: attributedText, in: clamped)
+ }
+
+ /// Best-effort markdown reconstruction for a partial selection.
+ static func extractMarkdown(from attributedText: NSAttributedString, in range: NSRange) -> String? {
+ guard let clamped = clampedRange(range, in: attributedText) else { return nil }
+
+ var result = ""
+ var state = ExtractionState()
+
+ // Headings may span multiple attribute runs.
+ var currentHeadingLevel: Int?
+ var headingContent = ""
+
+ func flushHeading() {
+ guard let level = currentHeadingLevel, !headingContent.isEmpty else {
+ currentHeadingLevel = nil
+ headingContent = ""
+ return
+ }
+ ensureBlankLine(&result)
+ result += String(repeating: "#", count: level) + " " + headingContent + "\n"
+ currentHeadingLevel = nil
+ headingContent = ""
+ state.needsBlankLine = true
+ }
+
+ attributedText.enumerateAttributes(in: clamped, options: []) { attrs, attrRange, _ in
+ let text = (attributedText.string as NSString).substring(with: attrRange)
+ guard !text.isEmpty else { return }
+
+ // Images and thematic breaks
+ if let attachment = attrs[.attachment] as? MarkdownImageAttachment {
+ guard !attachment.imageURL.isEmpty else { return }
+ if attachment.isInline {
+ result += ")"
+ } else {
+ ensureBlankLine(&result)
+ result += ")\n"
+ state.needsBlankLine = true
+ state.blockquoteDepth = -1
+ state.listDepth = -1
+ }
+ return
+ }
+
+ if attrs[.attachment] is ThematicBreakAttachment {
+ ensureBlankLine(&result)
+ result += "---\n"
+ state.needsBlankLine = true
+ state.blockquoteDepth = -1
+ state.listDepth = -1
+ return
+ }
+
+ if text == "\u{FFFC}" {
+ return
+ }
+
+ // Newline runs (paragraph breaks and margin/padding spacers).
+ // Checked before the code-block branch so code-block padding
+ // spacers never open or close fences.
+ if text.allSatisfy({ $0 == "\n" }) {
+ let inBlockquote = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.blockquoteDepth]
+ ) != nil
+ let inList = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.listDepth]
+ ) != nil
+
+ if !inBlockquote, state.blockquoteDepth >= 0 {
+ ensureBlankLine(&result)
+ state.blockquoteDepth = -1
+ return
+ }
+
+ if !inList, state.listDepth >= 0 {
+ ensureBlankLine(&result)
+ state.listDepth = -1
+ return
+ }
+
+ if inBlockquote || inList {
+ if !result.hasSuffix("\n") {
+ result += "\n"
+ }
+ return
+ }
+
+ ensureBlankLine(&result)
+ return
+ }
+
+ // Headings
+ if let level = MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.headingLevel]) {
+ if level != currentHeadingLevel {
+ flushHeading()
+ currentHeadingLevel = level
+ }
+ headingContent += text.trimmingCharacters(in: .newlines)
+ return
+ } else if currentHeadingLevel != nil {
+ flushHeading()
+ }
+
+ // Code blocks
+ if MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.codeBlock]) {
+ if state.needsBlankLine {
+ ensureBlankLine(&result)
+ state.needsBlankLine = false
+ }
+
+ if result.isEmpty || result.hasSuffix("\n\n") {
+ result += "```\n"
+ }
+
+ result += text
+
+ if text.hasSuffix("\n") {
+ result += "```\n"
+ state.needsBlankLine = true
+ }
+ return
+ }
+
+ // Blockquotes
+ let currentBlockquoteDepth = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.blockquoteDepth]
+ ) ?? -1
+ var blockquotePrefix: String?
+
+ if currentBlockquoteDepth >= 0 {
+ blockquotePrefix = Self.blockquotePrefix(depth: currentBlockquoteDepth)
+ state.blockquoteDepth = currentBlockquoteDepth
+ } else if state.blockquoteDepth >= 0 {
+ ensureBlankLine(&result)
+ state.blockquoteDepth = -1
+ }
+
+ // Lists
+ let currentListDepth = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.listDepth]
+ )
+
+ if let currentListDepth {
+ state.listDepth = currentListDepth
+ } else if state.listDepth >= 0 {
+ ensureBlankLine(&result)
+ state.listDepth = -1
+ }
+
+ // Inline formatting. Hard line breaks render as U+2028; map them
+ // back to newlines before wrapping.
+ let segmentText = text.replacingOccurrences(of: "\u{2028}", with: "\n")
+ var segment = applyInlineFormatting(segmentText, traits: InlineTraits(attrs: attrs))
+
+ // Block prefixes at line start
+ if isAtLineStart(result) {
+ var prefix = ""
+
+ if let currentListDepth, !text.hasPrefix("\n") {
+ let isOrdered = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.listType]
+ ) == ListType.ordered.rawValue
+ let itemNumber = MarkdownAttributeValue.intValue(
+ from: attrs[MarkdownAttribute.listItemNumber]
+ ) ?? 1
+ prefix += listPrefix(depth: currentListDepth, isOrdered: isOrdered, itemNumber: itemNumber)
+ }
+
+ if let blockquotePrefix {
+ prefix = blockquotePrefix + prefix
+ }
+
+ segment = prefix + segment
+ }
+
+ if state.needsBlankLine, !result.isEmpty {
+ ensureBlankLine(&result)
+ state.needsBlankLine = false
+ }
+
+ result += segment
+ }
+
+ flushHeading()
+
+ return result.isEmpty ? nil : result
+ }
+
+ /// http(s) URLs of image attachments within `range`, in document order.
+ static func imageURLs(in attributedText: NSAttributedString, range: NSRange) -> [String] {
+ guard let clamped = clampedRange(range, in: attributedText) else { return [] }
+
+ var urls: [String] = []
+ attributedText.enumerateAttribute(.attachment, in: clamped, options: []) { value, _, _ in
+ guard let attachment = value as? MarkdownImageAttachment else { return }
+ let url = attachment.imageURL
+ let lowercased = url.lowercased()
+ if lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") {
+ urls.append(url)
+ }
+ }
+ return urls
+ }
+}
+
+private extension MarkdownExtractor {
+ struct ExtractionState {
+ var blockquoteDepth = -1
+ var listDepth = -1
+ var needsBlankLine = false
+ }
+
+ struct InlineTraits {
+ let isInlineCode: Bool
+ let isStrong: Bool
+ let isEmphasis: Bool
+ let isStrikethrough: Bool
+ let isUnderline: Bool
+ let linkURL: String?
+
+ init(attrs: [NSAttributedString.Key: Any]) {
+ isInlineCode = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.inlineCode])
+ isStrong = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.strong])
+ isEmphasis = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.emphasis])
+ isStrikethrough = (MarkdownAttributeValue.intValue(from: attrs[.strikethroughStyle]) ?? 0) != 0
+ isUnderline = (MarkdownAttributeValue.intValue(from: attrs[.underlineStyle]) ?? 0) != 0
+
+ switch attrs[.link] {
+ case let url as URL:
+ linkURL = url.absoluteString
+ case let string as String:
+ linkURL = string
+ default:
+ linkURL = nil
+ }
+ }
+ }
+
+ static func clampedRange(_ range: NSRange, in attributedText: NSAttributedString) -> NSRange? {
+ guard range.location != NSNotFound,
+ range.location >= 0,
+ range.length > 0,
+ range.location < attributedText.length
+ else {
+ return nil
+ }
+ return NSRange(
+ location: range.location,
+ length: min(range.length, attributedText.length - range.location)
+ )
+ }
+
+ /// A selection is "full" when it starts at the beginning and any excluded
+ /// tail is whitespace-only (themes append 1–2 trailing margin spacers that
+ /// Select All may skip).
+ static func isFullSelection(_ range: NSRange, in attributedText: NSAttributedString) -> Bool {
+ guard range.location == 0 else { return false }
+ let tail = (attributedText.string as NSString).substring(from: NSMaxRange(range))
+ return tail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ static func ensureBlankLine(_ result: inout String) {
+ guard !result.isEmpty, !result.hasSuffix("\n\n") else { return }
+ result += result.hasSuffix("\n") ? "\n" : "\n\n"
+ }
+
+ static func isAtLineStart(_ result: String) -> Bool {
+ result.isEmpty || result.hasSuffix("\n")
+ }
+
+ /// Depth 0 = "> ", depth 1 = "> > ", etc.
+ static func blockquotePrefix(depth: Int) -> String {
+ String(repeating: "> ", count: depth + 1)
+ }
+
+ static func listPrefix(depth: Int, isOrdered: Bool, itemNumber: Int) -> String {
+ let indent = String(repeating: " ", count: depth * 2)
+ let marker = isOrdered ? "\(itemNumber)." : "-"
+ return "\(indent)\(marker) "
+ }
+
+ static func applyInlineFormatting(_ text: String, traits: InlineTraits) -> String {
+ var result = text
+
+ // Innermost first
+ if traits.isInlineCode, traits.linkURL == nil {
+ result = "`\(result)`"
+ }
+ if traits.isStrikethrough {
+ result = "~~\(result)~~"
+ }
+ if traits.isUnderline, traits.linkURL == nil {
+ result = "\(result)"
+ }
+ if traits.isEmphasis {
+ result = "*\(result)*"
+ }
+ if traits.isStrong {
+ result = "**\(result)**"
+ }
+ if let linkURL = traits.linkURL {
+ result = "[\(result)](\(linkURL))"
+ }
+
+ return result
+ }
+}
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift
index 7a61a66e..7bf45c80 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift
@@ -24,6 +24,9 @@ struct BlockStyle {
enum MarkdownAttribute {
static let inlineCode = NSAttributedString.Key("EnrichedMarkdownInlineCode")
static let codeBlock = NSAttributedString.Key("EnrichedMarkdownCodeBlock")
+ static let headingLevel = NSAttributedString.Key("EnrichedMarkdownHeadingLevel")
+ static let strong = NSAttributedString.Key("EnrichedMarkdownStrong")
+ static let emphasis = NSAttributedString.Key("EnrichedMarkdownEmphasis")
static let blockquoteDepth = NSAttributedString.Key("EnrichedMarkdownBlockquoteDepth")
static let blockquoteBackgroundColor = NSAttributedString.Key("EnrichedMarkdownBlockquoteBackgroundColor")
static let listDepth = NSAttributedString.Key("EnrichedMarkdownListDepth")
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift
index 4ceb990c..f3e0a012 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift
@@ -16,6 +16,8 @@ final class EmphasisRenderer: NodeRenderer {
let range = RenderContext.rangeForRenderedContent(in: output, start: start)
guard range.length > 0 else { return }
+ output.addAttribute(MarkdownAttribute.emphasis, value: true, range: range)
+
let blockStyle = context.getBlockStyle()
let blockColor = blockStyle?.color ?? UIColor.label
let emphasisColor = config.emphasis.foregroundColor
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 1aecc2f7..0dae4c2f 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift
@@ -35,6 +35,12 @@ final class HeadingRenderer: NodeRenderer {
guard output.length > start else { return }
+ output.addAttribute(
+ MarkdownAttribute.headingLevel,
+ value: level,
+ range: NSRange(location: contentStart, length: output.length - contentStart)
+ )
+
let range = NSRange(location: start, length: output.length - start)
if let lineHeight = headingStyle.lineHeight {
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift
index 59c75817..3b885a14 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift
@@ -16,6 +16,8 @@ final class StrongRenderer: NodeRenderer {
let range = RenderContext.rangeForRenderedContent(in: output, start: start)
guard range.length > 0 else { return }
+ output.addAttribute(MarkdownAttribute.strong, value: true, range: range)
+
let blockStyle = context.getBlockStyle()
let blockColor = blockStyle?.color ?? UIColor.label
let strongColor = RenderContext.calculateStrongColor(
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift
index 9354dab5..8117130d 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift
@@ -27,6 +27,7 @@ public struct EnrichedMarkdownText: View {
public var body: some View {
MarkdownTextViewRepresentable(
attributedText: renderStore.attributedText,
+ sourceMarkdown: renderStore.sourceMarkdown,
styleConfig: styleConfig,
onLinkPress: onLinkPress
)
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift
index 7f9a2ae3..7eeb259c 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift
@@ -4,6 +4,9 @@ import UIKit
@MainActor
final class MarkdownRenderStore: ObservableObject {
@Published private(set) var attributedText = NSAttributedString()
+ // Published together with `attributedText` so consumers never pair a new
+ // markdown string with a stale render result.
+ @Published private(set) var sourceMarkdown: String?
private let coordinator = AsyncRenderCoordinator()
@@ -14,6 +17,7 @@ final class MarkdownRenderStore: ObservableObject {
) {
if isBlank(markdown) {
attributedText = NSAttributedString()
+ sourceMarkdown = nil
return
}
@@ -21,6 +25,7 @@ final class MarkdownRenderStore: ObservableObject {
MarkdownRenderer.render(markdown, config: config, flags: flags)
} apply: { [weak self] result in
self?.attributedText = result
+ self?.sourceMarkdown = markdown
}
}
diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift
index a9dba8b2..4d02681d 100644
--- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift
+++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift
@@ -3,6 +3,7 @@ import UIKit
struct MarkdownTextViewRepresentable: UIViewRepresentable {
let attributedText: NSAttributedString
+ let sourceMarkdown: String?
let styleConfig: MarkdownStyleConfig
let onLinkPress: ((URL) -> Void)?
@@ -19,6 +20,7 @@ struct MarkdownTextViewRepresentable: UIViewRepresentable {
func updateUIView(_ textView: MarkdownTextView, context: Context) {
context.coordinator.onLinkPress = onLinkPress
+ context.coordinator.sourceMarkdown = sourceMarkdown
textView.styleConfig = styleConfig
textView.setMarkdownAttributedText(attributedText)
}
@@ -36,6 +38,7 @@ struct MarkdownTextViewRepresentable: UIViewRepresentable {
final class Coordinator: NSObject, UITextViewDelegate {
var onLinkPress: ((URL) -> Void)?
+ var sourceMarkdown: String?
func textView(
_ textView: UITextView,
diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift
new file mode 100644
index 00000000..fdf5f08b
--- /dev/null
+++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift
@@ -0,0 +1,377 @@
+import UIKit
+import XCTest
+@testable import EnrichedMarkdown
+
+final class MarkdownExtractorTests: XCTestCase {
+ private var config: MarkdownStyleConfig!
+
+ override func setUp() {
+ super.setUp()
+ config = MarkdownStyleConfig.baseline()
+ }
+
+ // MARK: - Helpers
+
+ private func render(_ markdown: String, flags: Md4cFlags = .commonMark) -> NSAttributedString {
+ MarkdownRenderer.render(markdown, config: config, flags: flags)
+ }
+
+ private func extractSelecting(
+ _ substring: String,
+ in markdown: String,
+ flags: Md4cFlags = .commonMark,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) -> String? {
+ let rendered = render(markdown, flags: flags)
+ let range = (rendered.string as NSString).range(of: substring)
+ XCTAssertNotEqual(
+ range.location, NSNotFound,
+ "substring '\(substring)' not found in rendered output '\(rendered.string)'",
+ file: file, line: line
+ )
+ guard range.location != NSNotFound else { return nil }
+ return MarkdownExtractor.extractMarkdown(from: rendered, in: range)
+ }
+
+ private func extractFullRange(_ markdown: String, flags: Md4cFlags = .commonMark) -> String? {
+ let rendered = render(markdown, flags: flags)
+ return MarkdownExtractor.extractMarkdown(
+ from: rendered,
+ in: NSRange(location: 0, length: rendered.length)
+ )
+ }
+
+ // MARK: - Invalid input
+
+ func testReturnsNilForEmptyRange() {
+ let rendered = render("Hello")
+ XCTAssertNil(MarkdownExtractor.extractMarkdown(from: rendered, in: NSRange(location: 2, length: 0)))
+ }
+
+ func testReturnsNilForOutOfBoundsRange() {
+ let rendered = render("Hello")
+ XCTAssertNil(
+ MarkdownExtractor.extractMarkdown(
+ from: rendered,
+ in: NSRange(location: rendered.length + 5, length: 3)
+ )
+ )
+ }
+
+ // MARK: - Full selection
+
+ func testFullSelectionReturnsSourceMarkdownVerbatim() {
+ let source = "# Title\n\nParagraph with **bold**."
+ let rendered = render(source)
+
+ let markdown = MarkdownExtractor.markdown(
+ for: NSRange(location: 0, length: rendered.length),
+ in: rendered,
+ sourceMarkdown: source
+ )
+
+ XCTAssertEqual(markdown, source)
+ }
+
+ func testFullSelectionToleratesExcludedTrailingSpacers() {
+ let source = "# Title\n\nParagraph with **bold**."
+ let rendered = render(source)
+
+ let markdown = MarkdownExtractor.markdown(
+ for: NSRange(location: 0, length: rendered.length - 1),
+ in: rendered,
+ sourceMarkdown: source
+ )
+
+ XCTAssertEqual(markdown, source)
+ }
+
+ func testPartialSelectionDoesNotReturnSourceMarkdown() {
+ let source = "First paragraph.\n\nSecond paragraph."
+ let rendered = render(source)
+ let range = (rendered.string as NSString).range(of: "Second paragraph.")
+
+ let markdown = MarkdownExtractor.markdown(for: range, in: rendered, sourceMarkdown: source)
+
+ XCTAssertEqual(markdown, "Second paragraph.")
+ }
+
+ // MARK: - Inline elements
+
+ func testExtractsPlainParagraphText() {
+ XCTAssertEqual(extractSelecting("Hello CommonMark", in: "Hello CommonMark"), "Hello CommonMark")
+ }
+
+ func testExtractsPartialSelectionWithinParagraph() {
+ XCTAssertEqual(extractSelecting("Hello", in: "Hello world"), "Hello")
+ }
+
+ func testExtractsBoldText() {
+ XCTAssertEqual(extractSelecting("31%", in: "Forests cover **31%** of land."), "**31%**")
+ }
+
+ func testExtractsItalicText() {
+ XCTAssertEqual(
+ extractSelecting("300 million years", in: "Over *300 million years* old."),
+ "*300 million years*"
+ )
+ }
+
+ func testExtractsBoldAndItalicInSameParagraph() {
+ XCTAssertEqual(
+ extractSelecting(
+ "Text with bold and italic styles.",
+ in: "Text with **bold** and *italic* styles."
+ ),
+ "Text with **bold** and *italic* styles."
+ )
+ }
+
+ func testExtractsInlineCode() {
+ XCTAssertEqual(extractSelecting("48 pounds", in: "Use `48 pounds` per year."), "`48 pounds`")
+ }
+
+ func testExtractsLink() {
+ XCTAssertEqual(
+ extractSelecting("Example link", in: "[Example link](https://example.com)"),
+ "[Example link](https://example.com)"
+ )
+ }
+
+ func testExtractsStrikethrough() {
+ XCTAssertEqual(extractSelecting("old value", in: "Price: ~~old value~~ new."), "~~old value~~")
+ }
+
+ func testExtractsUnderline() {
+ XCTAssertEqual(
+ extractSelecting(
+ "underlined",
+ in: "Some _underlined_ text.",
+ flags: Md4cFlags(underline: true)
+ ),
+ "underlined"
+ )
+ }
+
+ func testLinkIsNotWrappedInUnderline() {
+ XCTAssertEqual(
+ extractSelecting("swmansion", in: "Visit [swmansion](https://swmansion.com) now."),
+ "[swmansion](https://swmansion.com)"
+ )
+ }
+
+ // MARK: - Headings
+
+ func testExtractsHeading() {
+ XCTAssertEqual(
+ extractSelecting(
+ "The Hidden World of Forest Ecosystems",
+ in: "# The Hidden World of Forest Ecosystems"
+ ),
+ "# The Hidden World of Forest Ecosystems\n"
+ )
+ }
+
+ func testExtractsAllHeadingLevels() {
+ for level in 1...6 {
+ let title = "Heading level \(level)"
+ let expected = String(repeating: "#", count: level) + " " + title + "\n"
+ let source = String(repeating: "#", count: level) + " " + title
+ XCTAssertEqual(extractSelecting(title, in: source), expected)
+ }
+ }
+
+ func testHeadingAttributeAppliedByRenderer() {
+ let rendered = render("# Hi")
+ let textRange = (rendered.string as NSString).range(of: "Hi")
+
+ let level = MarkdownAttributeValue.intValue(
+ from: rendered.attribute(MarkdownAttribute.headingLevel, at: textRange.location, effectiveRange: nil)
+ )
+
+ XCTAssertEqual(level, 1)
+ }
+
+ // MARK: - Blockquotes
+
+ func testExtractsBlockquote() {
+ let quote = "In every walk with nature, one receives far more than he seeks."
+ XCTAssertEqual(extractSelecting(quote, in: "> \(quote)"), "> \(quote)")
+ }
+
+ func testExtractsNestedBlockquote() {
+ XCTAssertEqual(
+ extractSelecting("Inner quote", in: "> Outer quote\n>\n> > Inner quote"),
+ "> > Inner quote"
+ )
+ }
+
+ func testExtractsBlockquoteWithFormattedText() {
+ XCTAssertEqual(
+ extractSelecting("Quote with bold text.", in: "> Quote with **bold** text."),
+ "> Quote with **bold** text."
+ )
+ }
+
+ // MARK: - Lists
+
+ func testExtractsUnorderedListItem() {
+ XCTAssertEqual(
+ extractSelecting("Climate regulation", in: "- Climate regulation\n- Biodiversity"),
+ "- Climate regulation"
+ )
+ }
+
+ func testExtractsOrderedListItem() {
+ XCTAssertEqual(
+ extractSelecting("First item", in: "1. First item\n2. Second item"),
+ "1. First item"
+ )
+ }
+
+ func testExtractsSecondOrderedListItem() {
+ XCTAssertEqual(
+ extractSelecting("Second item", in: "1. First item\n2. Second item"),
+ "2. Second item"
+ )
+ }
+
+ func testExtractsNestedUnorderedListItem() {
+ XCTAssertEqual(
+ extractSelecting("Nested item", in: "- Parent item\n - Nested item"),
+ " - Nested item"
+ )
+ }
+
+ func testExtractsMultipleListItems() {
+ let rendered = render("- Alpha\n- Beta")
+ let string = rendered.string as NSString
+ let start = string.range(of: "Alpha")
+ let end = string.range(of: "Beta")
+ let range = NSRange(location: start.location, length: NSMaxRange(end) - start.location)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: range),
+ "- Alpha\n- Beta"
+ )
+ }
+
+ // MARK: - Code blocks
+
+ func testExtractsMultiLineCodeBlock() {
+ let code = "func main() {\n print(\"forest\")\n}\n"
+ let rendered = render("```\nfunc main() {\n print(\"forest\")\n}\n```")
+ let range = (rendered.string as NSString).range(of: code)
+ XCTAssertNotEqual(range.location, NSNotFound)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: range),
+ "```\n\(code)```\n"
+ )
+ }
+
+ func testExtractsCodeBlockWithoutClosingFenceWhenSelectionExcludesTrailingNewline() {
+ let code = "let answer = 42"
+ XCTAssertEqual(
+ extractSelecting(code, in: "```\nlet answer = 42\n```"),
+ "```\n\(code)"
+ )
+ }
+
+ // MARK: - Thematic breaks and images
+
+ func testExtractsThematicBreak() {
+ let rendered = render("Before\n\n---\n\nAfter")
+ let breakLocation = (rendered.string as NSString).range(of: "\u{FFFC}")
+ XCTAssertNotEqual(breakLocation.location, NSNotFound)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: breakLocation),
+ "---\n"
+ )
+ }
+
+ func testExtractsInlineImage() {
+ let url = "https://example.com/forest.jpg"
+ let rendered = render("Before ) after")
+ let imageLocation = (rendered.string as NSString).range(of: "\u{FFFC}")
+ XCTAssertNotEqual(imageLocation.location, NSNotFound)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: imageLocation),
+ ")"
+ )
+ }
+
+ func testExtractsBlockImage() {
+ let url = "https://example.com/forest.jpg"
+ let rendered = render(")")
+ let imageLocation = (rendered.string as NSString).range(of: "\u{FFFC}")
+ XCTAssertNotEqual(imageLocation.location, NSNotFound)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: imageLocation),
+ ")\n"
+ )
+ }
+
+ func testExtractsParagraphWithInlineImageAndText() {
+ let url = "https://example.com/forest.jpg"
+ let rendered = render("See ) for details.")
+ let string = rendered.string as NSString
+ let start = string.range(of: "See ")
+ let end = string.range(of: " for details.")
+ let range = NSRange(location: start.location, length: NSMaxRange(end) - start.location)
+
+ XCTAssertEqual(
+ MarkdownExtractor.extractMarkdown(from: rendered, in: range),
+ "See ) for details."
+ )
+ }
+
+ // MARK: - Line breaks
+
+ func testSoftBreakExtractsAsSpace() {
+ XCTAssertEqual(extractSelecting("alpha beta", in: "alpha\nbeta"), "alpha beta")
+ }
+
+ func testHardBreakExtractsAsNewline() {
+ let extracted = extractFullRange("line one \nline two")
+ XCTAssertNotNil(extracted)
+ XCTAssertTrue(
+ extracted?.contains("line one\nline two") ?? false,
+ "expected hard break as newline in '\(extracted ?? "nil")'"
+ )
+ }
+
+ // MARK: - Image URLs
+
+ func testImageURLsReturnsHttpUrlsInRange() {
+ let url = "https://example.com/forest.jpg"
+ let rendered = render(")")
+
+ XCTAssertEqual(
+ MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: rendered.length)),
+ [url]
+ )
+ }
+
+ func testImageURLsFiltersNonHttpSchemes() {
+ let rendered = render("")
+
+ XCTAssertEqual(
+ MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: rendered.length)),
+ []
+ )
+ }
+
+ func testImageURLsReturnsEmptyForEmptyRange() {
+ let rendered = render("")
+
+ XCTAssertEqual(
+ MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: 0)),
+ []
+ )
+ }
+}