0

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

1 Answer 1

1

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...
}
Sign up to request clarification or add additional context in comments.

2 Comments

This definitely answers my question–but unfortunately creates the next question: how to handle indentations. As this was not part of the original question, I will ask another question for that specifically though.
@appfrosch Isn't that just firstLineHeadIndent and headIndent properties of the paragraph style?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.