Skip to main content
MarkdownView provides robust image handling with asynchronous loading, memory caching, URL caching, and support for both local and remote images.

Overview

Images in markdown (![alt text](url)) are handled by the ImageLoader singleton, which:
  • Loads images asynchronously from URLs
  • Caches images in memory for fast access
  • Supports URL session disk cache
  • Handles local file URLs
  • Posts notifications when images finish loading
  • Renders images inline with text using LTXAttachment

ImageLoader Architecture

The ImageLoader class is a singleton that manages all image loading operations:
public final class ImageLoader {
    public static let shared = ImageLoader()
    
    private let cache = NSCache<NSString, PlatformImage>()
    private let session: URLSession
    private var inFlightTasks: [URL: URLSessionDataTask] = [:]
    private let lock = NSLock()
}

Memory Cache

Images are cached in memory using NSCache with a limit of 128 images (ImageLoader.swift:31):
cache.countLimit = 128
NSCache automatically evicts images under memory pressure, so your app won’t crash if it loads many large images.

URL Cache

The URLSession is configured with both memory and disk caching for persistent storage across app launches:
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
    memoryCapacity: 20 * 1024 * 1024,  // 20 MB
    diskCapacity: 100 * 1024 * 1024     // 100 MB
)
session = URLSession(configuration: config)

Async Loading

Images are loaded asynchronously to prevent blocking the main thread. The loading flow:
  1. Check memory cache - Instant return if cached
  2. Check URL cache - URLSession automatically checks disk cache
  3. Download - Fetch from network if not cached
  4. Cache and notify - Store in memory cache and post notification
From ImageLoader.swift:42-54:
public func loadImage(
    from urlString: String,
    completion: @escaping (PlatformImage?) -> Void
) {
    // Check memory cache
    if let cached = cache.object(forKey: cacheKey) {
        completion(cached)
        return
    }
    // ... async download
}
The completion handler is always called on the main thread, making it safe to update UI directly.

Local File Support

Local file URLs are supported and loaded synchronously since they don’t require network access:
if url.isFileURL {
    if let image = PlatformImage(contentsOfFile: url.path) {
        cache.setObject(image, forKey: cacheKey)
        completion(image)
    } else {
        completion(nil)
    }
    return
}
This allows you to reference bundled images or documents directory images in your markdown:
![Local Image](file:///path/to/image.png)

In-Flight Task Management

To prevent duplicate downloads, the loader tracks in-flight requests:
lock.lock()
if inFlightTasks[url] != nil {
    lock.unlock()
    return // Already fetching
}
lock.unlock()
If multiple markdown views request the same image URL before it loads, only one network request is made.

Notification System

When an image finishes loading, a notification is posted on the main thread:
public static let imageDidLoadNotification = 
    Notification.Name("ImageLoader.imageDidLoad")
The notification’s object is the URL string. MarkdownView observes this notification and automatically re-renders affected views (MarkdownTextView+Private.swift:246-263):
@objc func handleImageDidLoad(_ notification: Notification) {
    guard !document.blocks.isEmpty else { return }
    if let imageSource = notification.object as? String,
       !document.imageSources.contains(imageSource) {
        return
    }
    use(document) // Re-render to show the loaded image
}
Views only re-render if the loaded image URL is present in their document, avoiding unnecessary work.

Image Rendering

Images are rendered as inline attachments using LTXAttachment. The rendering process:

Initial State: Placeholder

Before an image loads, a text placeholder is shown as a link:
let placeholderText = altText.isEmpty ? source : altText
return NSAttributedString(
    string: placeholderText,
    attributes: [
        .link: source,
        .foregroundColor: theme.colors.highlight,
    ]
)

After Loading: Image Attachment

Once cached, the image is rendered using a custom drawing callback (InlineNode+Render.swift:213-279):
let drawingCallback = LTXLineDrawingAction { context, line, lineOrigin in
    let rect = CGRect(
        x: lineOrigin.x + runOffsetX,
        y: lineOrigin.y,
        width: imageSize.width,
        height: imageSize.height
    )
    // Platform-specific drawing
    image.draw(in: rect)
}

Image Sizing

Images are automatically scaled to fit within a maximum width while preserving aspect ratio:
var imageSize = image.size
let maxWidth: CGFloat = 600
if imageSize.width > maxWidth {
    let scale = maxWidth / imageSize.width
    imageSize = CGSize(width: maxWidth, height: imageSize.height * scale)
}
The 600pt maximum width ensures images don’t overflow on most devices while maintaining readability.

Image Tap Handler

You can handle taps on images by setting the imageTapHandler property on MarkdownTextView:
markdownView.imageTapHandler = { imageURL, tapLocation in
    print("Tapped image: \(imageURL) at \(tapLocation)")
    // Open full-screen viewer, share, etc.
}
The handler receives:
  • imageURL: The source URL string from the markdown
  • tapLocation: CGPoint of the tap in the view’s coordinate space
From MarkdownTextView+LTXDelegate.swift:
if let source = highlightRegion.attributes[.imageSource] as? String {
    imageTapHandler?(source, location)
    return
}

Image Metadata

Images carry metadata in their attributed string attributes:
  • .imageSource: Original URL string
  • .link: URL for tap detection
  • .contextIdentifier: Unique identifier for drawing callbacks
  • LTXAttachmentAttributeName: The attachment object
This metadata enables features like tap handling, accessibility, and proper text selection.

Accessibility

Images use their alt text for VoiceOver. The LTXAttachment stores the alt text or URL as its string representation:
let representedText = altText.isEmpty ? source : altText
let attachment = LTXAttachment.hold(attrString: .init(string: representedText))
When VoiceOver reads the content, it speaks the alt text instead of the replacement character.

Error Handling

If an image fails to load:
  1. The completion handler receives nil
  2. The placeholder text remains visible as a link
  3. The user can tap the link to open the URL in a browser
  4. An error is logged in debug builds:
#if DEBUG
    Self.log.warning("failed: \(urlString) error=\(error?.localizedDescription ?? \"bad data\")")
#endif
Invalid URLs or network errors result in the alt text/URL remaining as a clickable link. Always provide meaningful alt text for better user experience.

Performance Best Practices

  1. Use appropriate image sizes: Don’t embed 4K images in markdown - they’ll be scaled down anyway
  2. Leverage caching: Reference the same image multiple times without penalty
  3. Provide alt text: Makes placeholders more readable while loading
  4. Use local images for bundled assets: Faster loading with no network dependency

Synchronous Cache Check

You can synchronously check if an image is cached without triggering a load:
if let cachedImage = ImageLoader.shared.cachedImage(for: "https://example.com/image.png") {
    // Image is already cached
}
This is useful for preflighting or conditional UI updates.

Build docs developers (and LLMs) love