swiftattributedstring

Render markdown string into AttributedString


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
  }
}

Solution

  • 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...
    }