Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import UIKit

final class AttributedRenderer {
private let config: MarkdownStyleConfig
private let factory: RendererFactory

init(config: MarkdownStyleConfig) {
self.config = config
self.factory = RendererFactory(config: config)
}

func renderRoot(_ root: MarkdownASTNode) -> NSMutableAttributedString {
let context = RenderContext()
let output = NSMutableAttributedString()

let paragraphFont = config.paragraph.font ?? UIFont.preferredFont(forTextStyle: .body)
let paragraphColor = config.paragraph.foregroundColor ?? UIColor.label
context.setBlockStyle(font: paragraphFont, color: paragraphColor)

for child in root.children {
factory.renderer(for: child.type).render(node: child, into: output, context: context)
}

context.clearBlockStyle()
return output
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import UIKit

enum FontHelpers {
static func ensureBold(_ font: UIFont?) -> UIFont? {
guard let font else { return nil }
let traits = font.fontDescriptor.symbolicTraits
guard !traits.contains(.traitBold) else { return font }

guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits.union(.traitBold)) else {
return font
}
return UIFont(descriptor: descriptor, size: 0)
}

static func ensureItalic(_ font: UIFont?) -> UIFont? {
guard let font else { return nil }
let traits = font.fontDescriptor.symbolicTraits
guard !traits.contains(.traitItalic) else { return font }

guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits.union(.traitItalic)) else {
return font
}
return UIFont(descriptor: descriptor, size: 0)
}

static func cachedFont(from blockStyle: BlockStyle?) -> UIFont? {
blockStyle?.font
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import UIKit

public enum MarkdownRenderer {
public static func render(
_ markdown: String,
config: MarkdownStyleConfig,
flags: Md4cFlags = .commonMark
) -> NSAttributedString {
let ast = Parser.shared.parseMarkdown(markdown, flags: flags)
let renderer = AttributedRenderer(config: config)
return renderer.renderRoot(ast)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit

protocol NodeRenderer: AnyObject {
func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext)
}

final class ChildrenOnlyRenderer: NodeRenderer {
private let factory: RendererFactory

init(factory: RendererFactory) {
self.factory = factory
}

func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) {
factory.renderChildren(of: node, into: output, context: context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import CoreText
import UIKit

enum ParagraphStyleHelpers {
static let newline = NSAttributedString(string: "\n")

@discardableResult
static func applyParagraphSpacingBefore(
to output: NSMutableAttributedString,
range: NSRange,
marginTop: CGFloat
) -> Int {
guard range.location > 0, marginTop > 0 else { return 0 }

let style = NSMutableParagraphStyle()
style.paragraphSpacingBefore = marginTop

let spacer = NSMutableAttributedString(attributedString: newline)
spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1))
output.insert(spacer, at: range.location)
return 1
}

@discardableResult
static func applyBlockSpacingBefore(
to output: NSMutableAttributedString,
at insertionPoint: Int,
marginTop: CGFloat
) -> Int {
guard marginTop > 0 else { return 0 }

let style = NSMutableParagraphStyle()
style.paragraphSpacingBefore = marginTop

let spacer = NSMutableAttributedString(attributedString: newline)
spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1))
output.insert(spacer, at: insertionPoint)
return 1
}

/// Appends a trailing newline and sets `paragraphSpacing` on the block paragraph.
/// UITextView lays out `paragraphSpacing` reliably; a dedicated spacer line with only
/// `minimumLineHeight`/`maximumLineHeight` is often collapsed to zero height.
static func applyParagraphSpacingAfter(
to output: NSMutableAttributedString,
at contentStart: Int,
marginBottom: CGFloat
) {
guard contentStart <= output.length else { return }

output.append(newline)

let style = getOrCreateParagraphStyle(in: output, at: contentStart)
style.paragraphSpacing = marginBottom

let range = NSRange(location: contentStart, length: output.length - contentStart)
output.addAttribute(.paragraphStyle, value: style, range: range)
}

static func applyLineHeight(
to output: NSMutableAttributedString,
range: NSRange,
lineHeight: CGFloat
) {
guard lineHeight > 0, range.length > 0 else { return }

let roundedLineHeight = ceil(lineHeight)
let style = getOrCreateParagraphStyle(in: output, at: range.location)
style.lineSpacing = 0
style.minimumLineHeight = roundedLineHeight
style.maximumLineHeight = roundedLineHeight
output.addAttribute(.paragraphStyle, value: style, range: range)
}

static func applyBaselineOffset(
to output: NSMutableAttributedString,
range: NSRange
) {
guard range.length > 0 else { return }

var maximumLineHeight: CGFloat = 0
output.enumerateAttribute(.paragraphStyle, in: range) { value, _, _ in
guard let paragraphStyle = value as? NSParagraphStyle else { return }
maximumLineHeight = max(paragraphStyle.maximumLineHeight, maximumLineHeight)
}
guard maximumLineHeight > 0 else { return }

output.enumerateAttribute(.font, in: range) { value, subrange, _ in
guard let font = value as? UIFont else { return }
guard output.attribute(.baselineOffset, at: subrange.location, effectiveRange: nil) == nil else {
return
}

let naturalHeight = typographicLineHeight(for: font)
guard naturalHeight > 0, maximumLineHeight >= naturalHeight else { return }

let leading = maximumLineHeight - naturalHeight
let baselineOffset = ceil(leading / 2)
output.addAttribute(.baselineOffset, value: baselineOffset, range: subrange)
}
}

static func applyBlockLineHeight(
to output: NSMutableAttributedString,
range: NSRange,
lineHeight: CGFloat
) {
applyLineHeight(to: output, range: range, lineHeight: lineHeight)
applyBaselineOffset(to: output, range: range)
}

static func applyTextAlignment(
to output: NSMutableAttributedString,
range: NSRange,
alignment: NSTextAlignment
) {
guard range.length > 0 else { return }

output.enumerateAttribute(
.paragraphStyle,
in: range,
options: []
) { value, subrange, _ in
let paragraphStyle: NSMutableParagraphStyle
if let existing = value as? NSParagraphStyle,
let mutable = existing.mutableCopy() as? NSMutableParagraphStyle {
paragraphStyle = mutable
} else {
paragraphStyle = NSMutableParagraphStyle()
}
paragraphStyle.alignment = alignment
output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange)
}
}

static func getOrCreateParagraphStyle(
in output: NSMutableAttributedString,
at index: Int
) -> NSMutableParagraphStyle {
if let existing = output.attribute(.paragraphStyle, at: index, effectiveRange: nil) as? NSParagraphStyle,
let mutable = existing.mutableCopy() as? NSMutableParagraphStyle {
return mutable
}
return NSMutableParagraphStyle()
}

static func applyHeadIndent(
to output: NSMutableAttributedString,
range: NSRange,
indent: CGFloat
) {
guard range.length > 0 else { return }

output.enumerateAttribute(
.paragraphStyle,
in: range,
options: []
) { value, subrange, _ in
let paragraphStyle: NSMutableParagraphStyle
if let existing = value as? NSParagraphStyle,
let mutable = existing.mutableCopy() as? NSMutableParagraphStyle {
paragraphStyle = mutable
} else {
paragraphStyle = NSMutableParagraphStyle()
}
paragraphStyle.firstLineHeadIndent = indent
paragraphStyle.headIndent = indent
output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange)
}
}

static func applyTextLists(
to output: NSMutableAttributedString,
range: NSRange,
lists: [NSTextList]
) {
guard range.length > 0 else { return }

output.enumerateAttribute(
.paragraphStyle,
in: range,
options: []
) { value, subrange, _ in
let paragraphStyle: NSMutableParagraphStyle
if let existing = value as? NSParagraphStyle,
let mutable = existing.mutableCopy() as? NSMutableParagraphStyle {
paragraphStyle = mutable
} else {
paragraphStyle = NSMutableParagraphStyle()
}
paragraphStyle.textLists = lists
output.addAttribute(.paragraphStyle, value: paragraphStyle, range: subrange)
}
}

static func applyTrailingVerticalPadding(
to output: NSMutableAttributedString,
range: NSRange,
padding: CGFloat
) {
guard padding > 0, range.length > 0 else { return }

let string = output.string as NSString
let lastParagraphLocation = max(range.location, NSMaxRange(range) - 1)
let lastParagraph = string.paragraphRange(for: NSRange(location: lastParagraphLocation, length: 0))
let style = getOrCreateParagraphStyle(in: output, at: lastParagraph.location)
style.paragraphSpacing = padding
output.addAttribute(.paragraphStyle, value: style, range: lastParagraph)
}

static func ensureTrailingNewline(in output: NSMutableAttributedString) {
guard output.length > 0, !output.string.hasSuffix("\n") else { return }
output.append(newline)
}

static func ensureStartingOnNewLine(in output: NSMutableAttributedString) {
guard output.length > 0, !output.string.hasSuffix("\n") else { return }
output.append(newline)
}

static func applyBlockSpacingAfter(
to output: NSMutableAttributedString,
marginBottom: CGFloat
) {
guard marginBottom > 0, output.length > 0 else { return }

let style = NSMutableParagraphStyle()
style.minimumLineHeight = 1
style.maximumLineHeight = 1
style.paragraphSpacing = marginBottom

let spacer = NSMutableAttributedString(attributedString: newline)
spacer.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: 1))
output.append(spacer)
}

/// Matches Android `LineHeightSpan` natural height: `(-ascent) + descent`.
private static func typographicLineHeight(for font: UIFont) -> CGFloat {
let ctFont = font as CTFont
return CTFontGetAscent(ctFont) + CTFontGetDescent(ctFont)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import UIKit

enum BlockType {
case none
case paragraph
}

struct BlockStyle {
var font: UIFont
var color: UIColor
}

enum MarkdownAttribute {
static let inlineCode = NSAttributedString.Key("EnrichedMarkdownInlineCode")
}

final class RenderContext {
private(set) var currentBlockType: BlockType = .none
private(set) var currentBlockStyle: BlockStyle?

func reset() {
currentBlockType = .none
currentBlockStyle = nil
}

func setBlockStyle(font: UIFont, color: UIColor) {
currentBlockType = .paragraph
currentBlockStyle = BlockStyle(font: font, color: color)
}

func clearBlockStyle() {
currentBlockType = .none
currentBlockStyle = nil
}

func getBlockStyle() -> BlockStyle? {
currentBlockStyle
}

func getTextAttributes() -> [NSAttributedString.Key: Any] {
guard let blockStyle = currentBlockStyle else {
return [:]
}
return [
.font: blockStyle.font,
.foregroundColor: blockStyle.color
]
}

static func shouldPreserveColors(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
attributes[.link] != nil || attributes[MarkdownAttribute.inlineCode] != nil
}

static func rangeForRenderedContent(in output: NSMutableAttributedString, start: Int) -> NSRange {
let length = output.length - start
guard length > 0 else { return NSRange(location: start, length: 0) }
return NSRange(location: start, length: length)
}

static func calculateStrongColor(configColor: UIColor?, blockColor: UIColor) -> UIColor? {
guard let configColor, !configColor.isEqual(blockColor) else {
return nil
}
return configColor
}
}
Loading