I am trying to render some markdown. I am handling headers already, thanks to this SO question. But now, I would also like to handle the bullet points and indentation, but I cannot figure out how to do that.
In the code below, when I reach case .unorderedList
, I would like to add a char resembling a bullet point and indentation (conditionally of course). I cannot mutate output[intentRange]
, which is of type AttributedSubstring
.
Trying to do say output[intentRange] = "⏺️" + output[intentRange]
throws an error telling me: Cannot assign value of type 'AttributedString' to subscript of type 'AttributedSubstring'
.
I am holding it wrong for sure, can someone tell me how to hold it correctly?
Here's the extension:
#if os(macOS)
import AppKit
#else
import UIKit
#endif
extension AttributedString {
#if os(macOS)
typealias PlatformFont = NSFont
#else
typealias PlatformFont = UIFont
#endif
/// This method allows to render not only the inline markdown tags, but also the different headers, bullet points, todos etc.
///
/// Inspired by [this so question](https://stackoverflow.com/questions/70643384/how-to-render-markdown-headings-in-swiftui-attributedstring).
init?(styledMarkdown markdownString: String, baseSize: Double = 14) {
let output = try? AttributedString(
markdown: markdownString,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
),
baseURL: nil
)
guard
var output = output
else { return nil }
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
let fontName = PlatformFont.preferredFont(forTextStyle: .body)
.fontName
let scaleFactor = 0.25
switch level {
case 1:
output[intentRange].font =
.custom(fontName, size: baseSize * (1 + scaleFactor * 4), relativeTo: .title)
.bold()
case 2:
output[intentRange].font =
.custom(fontName, size: baseSize * (1 + scaleFactor * 3), relativeTo: .title)
.bold()
case 3:
output[intentRange].font =
.custom(fontName, size: baseSize * (1 + scaleFactor * 2), relativeTo: .title)
.bold()
case 4:
output[intentRange].font =
.custom(fontName, size: baseSize * (1 + scaleFactor * 1), relativeTo: .title)
.bold()
default:
break
}
case .unorderedList:
//TODO: check for `- [ ]` here to show a checkbox or a checkmark depending on whether the task is marked done
//TODO: check for tab increments here!
AppLogger.misc.debug("\(output[intentRange]))")
// output[intentRange] = "⏺️" + output[intentRange] // this fails w/: `Cannot assign value of type 'AttributedString' to subscript of type 'AttributedSubstring'`
default:
break
}
}
if intentRange.lowerBound != output.startIndex {
output.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
}
}
self = output
}
}
You can create the final string you want, as an AttributedString
first, then get an AttributedString
from that using the subscript.
let bulleted = "⏺️" + output[intentRange]
output[intentRange] = bulleted[bulleted.startIndex...]
bulleted[bulleted.startIndex...]
gets the entire attributed string as a "substring". You can also write bulleted[bulleted.startIndex..<bulleted.endIndex]
, or bulleted[..<bulleted.endIndex]
. They all mean the same thing.
I'm not sure if you can add characters to a substring while iterating over it (even in reversed order). It feels a bit wrong to me. I'm much more comfortable with constructing a new string like this
let input = try? AttributedString(
markdown: markdownString,
options: ...,
baseURL: nil
)
guard let input else { return nil }
var output: AttributedString = ""
for run in input.runs {
var runCopy = AttributedString(input[run.range])
defer { output += runCopy }
// inspect the run and add attributes to runCopy here...
}