Skip to main content
The MarkdownView module transforms the parsed AST into rich, interactive native views. This involves converting block and inline nodes into NSAttributedString, embedding custom views for complex elements, and handling user interactions.

Rendering pipeline

The rendering process flows through several components:
1

Preprocess content

Generate syntax highlight maps and render math expressions.
2

Build attributed string

Use TextBuilder to convert AST blocks into NSAttributedString.
3

Layout text

Use LTXLabel to lay out the attributed string with Core Text.
4

Draw to screen

Render text, attachments, and embedded views.

LTXLabel: Custom text rendering

At the core of MarkdownView is LTXLabel, a custom text view built on Core Text. It’s part of the embedded Litext module and provides:

Features

High-performance layout

Uses Core Text for efficient glyph layout and rendering.

Inline attachments

Supports images, math, and custom views inline with text.

Text selection

Long-press, double-tap, and triple-tap gestures for selection.

Accessibility

Full VoiceOver support with semantic labels.

Core implementation

Sources/Litext/LTXLabel/LTXLabel.swift
public class LTXLabel: LTXPlatformView {
    public var attributedText: NSAttributedString = .init() {
        didSet { textLayout = LTXTextLayout(attributedString: attributedText) }
    }
    
    public var preferredMaxLayoutWidth: CGFloat = 0 {
        didSet {
            if preferredMaxLayoutWidth != oldValue {
                invalidateTextLayout()
            }
        }
    }
    
    public var isSelectable: Bool = false
    public var selectionBackgroundColor: PlatformColor?
    public weak var delegate: LTXLabelDelegate?
}
LTXLabel handles all text layout, rendering, and interaction. It’s used by MarkdownTextView to display the final rendered content.
LTXLabel is a generic text rendering component, not specific to markdown. It can display any NSAttributedString with custom attachments and drawing callbacks.

Text building

The TextBuilder class converts AST nodes into NSAttributedString:
Sources/MarkdownView/MarkdownTextBuilder/TextBuilder.swift
final class TextBuilder {
    func build() -> BuildResult {
        var subviewCollector = [PlatformView]()
        var segments = [NSAttributedString]()
        let processors = makeProcessors()
        
        for node in nodes {
            let segment = processBlock(
                node,
                context: context,
                blockProcessor: processors.blockProcessor,
                listProcessor: processors.listProcessor,
                subviews: &subviewCollector
            )
            segments.append(segment)
            text.append(segment)
        }
        
        text.fixAttributes(in: .init(location: 0, length: text.length))
        return .init(document: text, subviews: subviewCollector, blockSegments: segments)
    }
}

Block processing

Each block type has specialized rendering logic:
Block typeRendererOutput
HeadingBlockProcessor.processHeadingScaled font + bold
ParagraphBlockProcessor.processParagraphInline nodes
Code blockBlockProcessor.processCodeBlockCodeView attachment
TableBlockProcessor.processTableTableView attachment
ListListProcessor.processBulletedListBullet + indented items
BlockquoteBlockProcessor.processBlockquoteBorder + background
Thematic breakBlockProcessor.processThematicBreakHorizontal line
func processHeading(level: Int, contents: [MarkdownInlineNode]) -> NSAttributedString {
    let fontSize = theme.fonts.body.pointSize * theme.headingScale(for: level)
    let font = theme.fonts.bold.withSize(fontSize)
    
    let text = contents.render(theme: theme, context: context, viewProvider: viewProvider)
    text.addAttributes(
        [.font: font, .foregroundColor: theme.colors.heading],
        range: NSRange(location: 0, length: text.length)
    )
    
    // Add spacing
    let spacing = NSMutableParagraphStyle()
    spacing.paragraphSpacing = theme.spacing.paragraph
    text.addAttribute(.paragraphStyle, value: spacing, range: NSRange(location: 0, length: text.length))
    
    return text
}

Inline rendering

Inline nodes are rendered recursively into NSAttributedString:
Sources/MarkdownView/Supplements/InlineNode+Render.swift
extension MarkdownInlineNode {
    func render(theme: MarkdownTheme, context: PreprocessedContent, viewProvider: ReusableViewProvider) -> NSAttributedString {
        switch self {
        case let .text(string):
            return NSMutableAttributedString(
                string: string,
                attributes: [.font: theme.fonts.body, .foregroundColor: theme.colors.body]
            )
        case let .strong(children):
            let text = children.render(theme: theme, context: context, viewProvider: viewProvider)
            text.addAttributes([.font: theme.fonts.bold], range: NSRange(location: 0, length: text.length))
            return text
        case let .emphasis(children):
            let text = children.render(theme: theme, context: context, viewProvider: viewProvider)
            text.addAttributes(
                [.underlineStyle: NSUnderlineStyle.thick.rawValue, .underlineColor: theme.colors.emphasis],
                range: NSRange(location: 0, length: text.length)
            )
            return text
        // ... other cases
        }
    }
}

Rendering inline math

Math expressions are rendered as inline images using the LTXAttachment system:
Sources/MarkdownView/Supplements/InlineNode+Render.swift
case let .math(content, replacementIdentifier):
    let latexContent = context.rendered[replacementIdentifier]?.text ?? content
    
    if let item = context.rendered[replacementIdentifier], let image = item.image {
        var imageSize = image.size
        
        let drawingCallback = LTXLineDrawingAction { context, line, lineOrigin in
            // Calculate position within the line
            let rect = CGRect(x: lineOrigin.x + runOffsetX, y: lineOrigin.y, width: imageSize.width, height: imageSize.height)
            
            // Draw the math image
            image.draw(in: rect)
        }
        
        let attachment = LTXAttachment.hold(attrString: .init(string: latexContent))
        attachment.size = imageSize
        
        return NSAttributedString(
            string: LTXReplacementText,
            attributes: [
                LTXAttachmentAttributeName: attachment,
                LTXLineDrawingCallbackName: drawingCallback,
                kCTRunDelegateAttributeName: attachment.runDelegate,
            ]
        )
    }
The LTXLineDrawingAction callback is invoked during Core Text rendering to draw the math image at the correct position.
Math images are rendered as template images on UIKit and use dynamic color resolution on AppKit, ensuring they adapt to dark mode automatically.

Rendering inline images

Images use the same attachment mechanism:
Sources/MarkdownView/Supplements/InlineNode+Render.swift
case let .image(source, children):
    // Check cache first
    if let image = ImageLoader.shared.cachedImage(for: source) {
        return Self.renderImage(image, source: source, altText: altText, theme: theme)
    }
    
    // Show placeholder link while loading
    return NSAttributedString(
        string: altText.isEmpty ? source : altText,
        attributes: [.link: source, .foregroundColor: theme.colors.highlight]
    )
Images load asynchronously. When an image finishes loading, a notification triggers re-rendering of the document.

Syntax highlighting with tree-sitter

Code blocks are highlighted using tree-sitter parsers. This is a key performance feature that eliminates the need for a JavaScript runtime.

Language parsers

MarkdownView includes 19 pre-compiled tree-sitter grammars:
Swift, C, C++, C#, Python, JavaScript, TypeScript, TSX, Go, Rust, Java, Kotlin, Ruby, Bash, SQL, YAML, JSON, HTML, CSS
Parsers are initialized lazily — only when a code block with that language is first encountered:
Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
private static var resolvedLanguages: [String: LanguageConfiguration] = [:]
private static let resolvedLanguagesLock = NSLock()

private static func languageConfiguration(for alias: String) -> LanguageConfiguration? {
    resolvedLanguagesLock.lock()
    defer { resolvedLanguagesLock.unlock() }
    
    if let cached = resolvedLanguages[alias] {
        return cached
    }
    guard let factory = languageFactories[alias],
          let config = factory() else {
        return nil
    }
    resolvedLanguages[alias] = config
    return config
}
Lazy loading keeps app launch fast. A document with only Python code will never load the Swift parser.

Highlighting algorithm

Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
private func highlightWithTreeSitter(language: String, content: String) -> HighlightMap {
    guard let config = Self.languageConfiguration(for: language) else { return [:] }
    guard let query = config.queries[.highlights] else { return [:] }
    
    let parser = Parser()
    try parser.setLanguage(config.language)
    
    guard let tree = parser.parse(content) else { return [:] }
    
    let cursor = query.execute(in: tree)
    let highlights = cursor.resolve(with: context).highlights()
    
    var map: HighlightMap = [:]
    for highlight in highlights {
        if let color = Self.color(forCapture: highlight.name) {
            map[highlight.range] = color
        }
    }
    return map
}
The result is a map from NSRange to PlatformColor, which is applied to the code’s attributed string:
let attributedContent = NSMutableAttributedString(
    string: content,
    attributes: [.font: theme.fonts.code, .foregroundColor: plainTextColor]
)

for (range, color) in highlightMap {
    attributedContent.addAttributes([.foregroundColor: color], range: range)
}

Capture-to-color mapping

Tree-sitter captures (like keyword, string, function) map to semantic colors:
Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
private static let captureColorMap: [String: PlatformColor] = {
    let keyword = dynamicColor(
        light: PlatformColor(red: 0.667, green: 0.051, blue: 0.569, alpha: 1),
        dark: PlatformColor(red: 0.988, green: 0.373, blue: 0.647, alpha: 1)
    )
    let string = dynamicColor(
        light: PlatformColor(red: 0.769, green: 0.102, blue: 0.086, alpha: 1),
        dark: PlatformColor(red: 0.988, green: 0.416, blue: 0.365, alpha: 1)
    )
    // ...
    return [
        "keyword": keyword,
        "string": string,
        "function": function,
        // ...
    ]
}()
Colors automatically adapt to light/dark mode using dynamic color providers.

Embedded views

Complex blocks like code and tables are rendered as standalone views embedded in the text:

CodeView

let codeView = viewProvider.dequeueCodeView()
codeView.configure(
    content: content,
    language: language,
    highlightMap: highlightMap,
    theme: theme
)

// Attach to attributed string
let attachment = LTXAttachment.hold(attrString: .init(string: content))
attachment.size = codeView.intrinsicContentSize
CodeView displays:
  • Syntax-highlighted text
  • Line numbers (optional)
  • Scroll support for long code
  • Copy button

TableView

let tableView = viewProvider.dequeueTableView()
tableView.configure(
    rows: rows,
    columnAlignments: alignments,
    theme: theme
)
TableView provides:
  • Grid layout with borders
  • Column alignment (left/center/right)
  • Header row styling
  • Cell content rendered as inline markdown
Both CodeView and TableView are reusable — the ReusableViewProvider pools them to avoid repeated allocations during scrolling.

Rendering example

Here’s a complete rendering example:
import MarkdownParser
import MarkdownView

let markdown = """
# Code Example

Here's a Swift function:

```swift
func greet(_ name: String) -> String {
    return "Hello, \(name)!"
}
Bold and italic text. """ // Parse let parser = MarkdownParser() let result = parser.parse(markdown) // Preprocess (on main thread for math rendering) let content = MarkdownTextView.PreprocessedContent( parserResult: result, theme: .default ) // Render let markdownView = MarkdownTextView() markdownView.setMarkdown(content)

The view now displays the markdown with:
- Heading in large bold font
- Syntax-highlighted Swift code in a CodeView
- Inline formatting (bold and italic)

See the [architecture overview](/core-concepts/architecture) for how parsing and rendering connect.

Build docs developers (and LLMs) love