import Foundation import Display private let controlStartCharactersSet = CharacterSet(charactersIn: "[*") private let controlCharactersSet = CharacterSet(charactersIn: "[]()*_-\\") final class MarkdownAttributeSet { let font: UIFont let textColor: UIColor let additionalAttributes: [String: Any] init(font: UIFont, textColor: UIColor, additionalAttributes: [String: Any] = [:]) { self.font = font self.textColor = textColor self.additionalAttributes = additionalAttributes } } final class MarkdownAttributes { let body: MarkdownAttributeSet let bold: MarkdownAttributeSet let link: MarkdownAttributeSet let linkAttribute: (String) -> (String, Any)? init(body: MarkdownAttributeSet, bold: MarkdownAttributeSet, link: MarkdownAttributeSet, linkAttribute: @escaping (String) -> (String, Any)?) { self.body = body self.link = link self.bold = bold self.linkAttribute = linkAttribute } } func escapedPlaintextForMarkdown(_ string: String) -> String { let nsString = string as NSString var remainingRange = NSMakeRange(0, nsString.length) let result = NSMutableString() while true { let range = nsString.rangeOfCharacter(from: controlCharactersSet, options: [], range: remainingRange) if range.location != NSNotFound { result.append("\\") result.append(nsString.substring(with: NSMakeRange(range.location, range.length))) remainingRange = NSMakeRange(range.location + range.length, remainingRange.location + remainingRange.length - (range.location + range.length)) } else { result.append(nsString.substring(with: NSMakeRange(remainingRange.location, remainingRange.length))) break } } return result as String } func paragraphStyleWithAlignment(_ alignment: NSTextAlignment) -> NSParagraphStyle { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = alignment return paragraphStyle } func parseMarkdownIntoAttributedString(_ string: String, attributes: MarkdownAttributes, textAlignment: NSTextAlignment = .natural) -> NSAttributedString { let nsString = string as NSString let result = NSMutableAttributedString() var remainingRange = NSMakeRange(0, nsString.length) var bodyAttributes: [NSAttributedStringKey: Any] = [NSAttributedStringKey.font: attributes.body.font, NSAttributedStringKey.foregroundColor: attributes.body.textColor, NSAttributedStringKey.paragraphStyle: paragraphStyleWithAlignment(textAlignment)] if !attributes.body.additionalAttributes.isEmpty { for (key, value) in attributes.body.additionalAttributes { bodyAttributes[NSAttributedStringKey(rawValue: key)] = value } } while true { let range = nsString.rangeOfCharacter(from: controlStartCharactersSet, options: [], range: remainingRange) if range.location != NSNotFound { if range.location != remainingRange.location { result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, range.location - remainingRange.location)), attributes: bodyAttributes)) remainingRange = NSMakeRange(range.location, remainingRange.location + remainingRange.length - range.location) } let character = nsString.character(at: range.location) if character == UInt16(("[" as UnicodeScalar).value) { remainingRange = NSMakeRange(range.location + range.length, remainingRange.location + remainingRange.length - (range.location + range.length)) if let (parsedLinkText, parsedLinkContents) = parseLink(string: nsString, remainingRange: &remainingRange) { var linkAttributes: [NSAttributedStringKey: Any] = [NSAttributedStringKey.font: attributes.link.font, NSAttributedStringKey.foregroundColor: attributes.link.textColor, NSAttributedStringKey.paragraphStyle: paragraphStyleWithAlignment(textAlignment)] if !attributes.link.additionalAttributes.isEmpty { for (key, value) in attributes.link.additionalAttributes { linkAttributes[NSAttributedStringKey(rawValue: key)] = value } } if let (attributeName, attributeValue) = attributes.linkAttribute(parsedLinkContents) { linkAttributes[NSAttributedStringKey(rawValue: attributeName)] = attributeValue } result.append(NSAttributedString(string: parsedLinkText, attributes: linkAttributes)) } } else if character == UInt16(("*" as UnicodeScalar).value) { if range.location + 1 != remainingRange.length { let nextCharacter = nsString.character(at: range.location + 1) if nextCharacter == character { remainingRange = NSMakeRange(range.location + range.length + 1, remainingRange.location + remainingRange.length - (range.location + range.length + 1)) if let bold = parseBold(string: nsString, remainingRange: &remainingRange) { var boldAttributes: [NSAttributedStringKey: Any] = [NSAttributedStringKey.font: attributes.bold.font, NSAttributedStringKey.foregroundColor: attributes.bold.textColor, NSAttributedStringKey.paragraphStyle: paragraphStyleWithAlignment(textAlignment)] if !attributes.body.additionalAttributes.isEmpty { for (key, value) in attributes.bold.additionalAttributes { boldAttributes[NSAttributedStringKey(rawValue: key)] = value } } result.append(NSAttributedString(string: bold, attributes: boldAttributes)) } else { result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) } } else { result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) } } else { result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) } } } else { if remainingRange.length != 0 { result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, remainingRange.length)), attributes: bodyAttributes)) } break } } return result } private func parseLink(string: NSString, remainingRange: inout NSRange) -> (text: String, contents: String)? { var localRemainingRange = remainingRange let closingSquareBraceRange = string.range(of: "]", options: [], range: localRemainingRange) if closingSquareBraceRange.location != NSNotFound { localRemainingRange = NSMakeRange(closingSquareBraceRange.location + closingSquareBraceRange.length, remainingRange.location + remainingRange.length - (closingSquareBraceRange.location + closingSquareBraceRange.length)) let openingRoundBraceRange = string.range(of: "(", options: [], range: localRemainingRange) let closingRoundBraceRange = string.range(of: ")", options: [], range: localRemainingRange) if openingRoundBraceRange.location == closingSquareBraceRange.location + closingSquareBraceRange.length && closingRoundBraceRange.location != NSNotFound && openingRoundBraceRange.location < closingRoundBraceRange.location { let linkText = string.substring(with: NSMakeRange(remainingRange.location, closingSquareBraceRange.location - remainingRange.location)) let linkContents = string.substring(with: NSMakeRange(openingRoundBraceRange.location + openingRoundBraceRange.length, closingRoundBraceRange.location - (openingRoundBraceRange.location + openingRoundBraceRange.length))) remainingRange = NSMakeRange(closingRoundBraceRange.location + closingRoundBraceRange.length, remainingRange.location + remainingRange.length - (closingRoundBraceRange.location + closingRoundBraceRange.length)) return (linkText, linkContents) } } return nil } private func parseBold(string: NSString, remainingRange: inout NSRange) -> String? { var localRemainingRange = remainingRange let closingRange = string.range(of: "**", options: [], range: localRemainingRange) if closingRange.location != NSNotFound { localRemainingRange = NSMakeRange(closingRange.location + closingRange.length, remainingRange.location + remainingRange.length - (closingRange.location + closingRange.length)) let result = string.substring(with: NSRange(location: remainingRange.location, length: closingRange.location - remainingRange.location)) remainingRange = localRemainingRange return result } return nil }