Skip to content

Instantly share code, notes, and snippets.

@chockenberry
Created June 1, 2022 21:08
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save chockenberry/ad744bacdc14a750e02e93063d0dc20a to your computer and use it in GitHub Desktop.
Save chockenberry/ad744bacdc14a750e02e93063d0dc20a to your computer and use it in GitHub Desktop.
A playground that shows how to use Swift's AttributedString with Markdown
import UIKit
import Foundation
// NOTE: This playground shows how to use Swift's AttributedString with Markdown.
//
// This code was used to display Markdown content in the Tot iOS Widget <https://tot.rocks>
// MARK: - Helpful Links
// NOTE: The following links helped me figure this stuff out.
/*
https://nilcoalescing.com/blog/AttributedStringAttributeScopes/
https://developer.apple.com/documentation/foundation/attributedstring/instantiating_attributed_strings_with_markdown_syntax
https://developer.apple.com/forums/thread/682957
*/
// MARK: - Test Data
// NOTE: This Arabic text below is used to test breaking the string into lines using AttributedSubstring.range(of:). Also of note:
// The Markdown parser is able to find the emphasis in the text, but it's unlikely that you'll have a font that's able to render
// it faithfully since italics really aren't a thing in non-Latin language (think about where the name "italic" came from).
// Similar issues will arise in Chinese/Japanese/Korean languages.
var text = """
**غامق** و*مائل* و **_كلاهما_**
مع اكثر من واحد
سطر من النص
"""
// NOTE: This string tests the styling, including a separate color for links (which overrides the text or accent color).
text = "\n\n**Bold** and _italic_ and **_both_**\n\nWith **[more](http://example.com) than** one\nline of <http:example.com> text"
// MARK: - Configuration
let fontFamilyName = "GillSans" // must be a font that has regular, bold, italic, and bold italic variants
let fontSize: CGFloat = 18
let textColor = UIColor.black
let accentColor = UIColor.red
let linkColor = UIColor.green
// MARK: - Testing
// generate an attributed string from Markdown text using the styling parameters configured above
if let attributedString = attributedMarkdownString(from: text, fontFamilyName: fontFamilyName, fontSize: fontSize, textColor: textColor, accentColor: accentColor, linkColor: linkColor) {
// the following examples show how to manipulate the AttributedString
// break the attributed string into lines by scanning the AttributedSubstring from the beginning to end
// (use the Arabic text above to challenge your assumption on what constitutes the beginning of a string)
do {
var substring = attributedString[attributedString.startIndex..<attributedString.endIndex]
var lineIndex = 0
while let lineRange = substring.range(of: "\n") {
let lineSubstring = attributedString[substring.startIndex..<lineRange.lowerBound]
emitLine(lineIndex: lineIndex, lineSubstring: lineSubstring)
substring = attributedString[lineRange.upperBound..<substring.endIndex]
lineIndex += 1
}
emitLine(lineIndex: lineIndex, lineSubstring: substring)
print("--------")
}
// find the first non-blank line in the attributed string (a "header")
do {
var foundHeader = false
var substring = attributedString[attributedString.startIndex..<attributedString.endIndex]
while let lineRange = substring.range(of: "\n") {
let lineSubstring = attributedString[substring.startIndex..<lineRange.lowerBound]
let characterCount = lineSubstring.characters.count
if characterCount > 0 {
print("header = \(lineSubstring.unadornedString)")
foundHeader = true
break
}
substring = attributedString[lineRange.upperBound..<substring.endIndex]
}
if !foundHeader {
print("header fallback = \(attributedString.unadornedString)")
}
print("--------")
}
// find the second non-blank line and remove everything before it (a "body")
do {
var attributedString = attributedString // we are going to modify the attributed string in place
var foundBody = false
var haveFirstLine = false
var substring = attributedString[attributedString.startIndex..<attributedString.endIndex]
while let lineRange = substring.range(of: "\n") {
let lineSubstring = attributedString[substring.startIndex..<lineRange.lowerBound]
let characterCount = lineSubstring.characters.count
if characterCount > 0 {
if !haveFirstLine {
haveFirstLine = true
}
else {
attributedString.removeSubrange(attributedString.startIndex..<substring.startIndex)
print("body = \(attributedString.unadornedString)")
foundBody = true
break
}
}
substring = attributedString[lineRange.upperBound..<substring.endIndex]
}
if !foundBody {
attributedString.removeSubrange(attributedString.startIndex..<substring.startIndex)
print("body fallback = \(attributedString.unadornedString)")
}
print("--------")
}
}
func emitLine(lineIndex: Int, lineSubstring: AttributedSubstring) {
let characterCount = lineSubstring.characters.count
if characterCount > 0 {
// non-empty line
print("line \(lineIndex): '\(NSAttributedString(AttributedString(lineSubstring)).string)'")
}
else {
// empty line
print("line \(lineIndex): empty")
}
}
// MARK: - Generate styled Markdown
func attributedMarkdownString(from text: String, fontFamilyName: String, fontSize: CGFloat, textColor: UIColor, accentColor: UIColor, linkColor: UIColor) -> AttributedString? {
// default fonts for all variants
var regularFont = UIFont.systemFont(ofSize: fontSize)
var italicFont = UIFont.systemFont(ofSize: fontSize)
var boldFont = UIFont.systemFont(ofSize: fontSize)
var boldItalicFont = UIFont.systemFont(ofSize: fontSize)
// use the family name to create a base font that will be used to fill in the variants
if let baseFont = UIFont(name: fontFamilyName, size: fontSize) {
regularFont = baseFont
if let italicFontDescriptor = baseFont.fontDescriptor.withSymbolicTraits(.traitItalic) {
italicFont = UIFont(descriptor: italicFontDescriptor, size: fontSize)
}
if let boldFontDescriptor = baseFont.fontDescriptor.withSymbolicTraits(.traitBold) {
boldFont = UIFont(descriptor: boldFontDescriptor, size: fontSize)
}
if let boldItalicFontDescriptor = baseFont.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) {
boldItalicFont = UIFont(descriptor: boldItalicFontDescriptor, size: fontSize)
}
}
else {
assert(false, "base font does not exist")
}
// build an attributed string from the Markdown syntax
if var attributedString = try? AttributedString(markdown: text, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
attributedString.font = regularFont
attributedString.foregroundColor = textColor
// get all the runs of attributes in the attributed string
for run in attributedString.runs {
let piece = attributedString[run.range] // this is only used for debugging
// inline presentation intent attributes let us find the Markdown runs that need styling
let intent = run.attributes[AttributeScopes.FoundationAttributes.InlinePresentationIntentAttribute.self]
if intent == .emphasized {
print("'\(piece.unadornedString)' is emphasized")
attributedString[run.range].font = italicFont
}
else if intent == .stronglyEmphasized {
print("'\(piece.unadornedString)' is strongly emphasized")
attributedString[run.range].font = boldFont
attributedString[run.range].foregroundColor = accentColor
}
else if intent == [ .stronglyEmphasized, .emphasized] {
print("'\(piece.unadornedString)' is both")
attributedString[run.range].font = boldItalicFont
attributedString[run.range].foregroundColor = accentColor
}
else {
print("'\(piece.unadornedString)' is normal")
}
// the link attribute lets us style the links in the Markdown
if let link = run.attributes[AttributeScopes.FoundationAttributes.LinkAttribute.self] {
attributedString[run.range].foregroundColor = linkColor
print("'\(piece.unadornedString)' is a link to \(link.absoluteString)")
}
}
//print(attributedString)
print("=========")
return attributedString
}
return nil
}
extension AttributedString {
var unadornedString: String {
get {
return NSAttributedString(self).string.replacingOccurrences(of: "\n", with: "\\n")
}
}
}
extension AttributedSubstring {
var unadornedString: String {
get {
return AttributedString(self).unadornedString
}
}
}
@chockenberry
Copy link
Author

A playground with this code can be downloaded here: https://files.iconfactory.net/craig/playgrounds/AttributedString.playground.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment