HuggingChat-Mac/Models/ModelAttributedStringParser.swift (377 lines of code) (raw):
///Credit: Alfian Losari https://github.com/alfianlosari/ChatGPTSwiftUI
import Foundation
import Highlighter
import Markdown
import SwiftUI
import AppKit
/// Based on the source code from Christian Selig
/// https://github.com/christianselig/Markdownosaur/blob/main/Sources/Markdownosaur/Markdownosaur.swift
public struct MarkdownAttributedStringParser: MarkupVisitor {
private let isDarkMode: Bool
let baseFontSize: CGFloat = NSFont.systemFont(ofSize: 14).pointSize
let highlighter: Highlighter = {
let highlighter = Highlighter()!
highlighter.setTheme("atom-one-dark", withFont: ".AppleSystemNSFontMonospaced-Regular", ofSize: 12)
highlighter.theme.lineSpacing = 5
return highlighter
}()
let newLineFontSize: CGFloat = Margin._10
public init(isDarkMode: Bool) {
self.isDarkMode = isDarkMode
}
public mutating func attributedString(from document: Document) -> NSAttributedString {
return visit(document)
}
mutating func parserResults(from document: Document) -> [ParserResult] {
var results = [ParserResult]()
let paragraph = NSMutableParagraphStyle()
paragraph.lineHeightMultiple = 1.45
var currentAttrString = NSMutableAttributedString(string: "", attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: isDarkMode ? NSColor.HF.gray300 : NSColor.HF.gray700, .paragraphStyle: paragraph])
func appendCurrentAttrString() {
if !currentAttrString.string.isEmpty {
results.append(.init(attributedString: currentAttrString, resultType: .text))
}
}
document.children.enumerated().forEach { (index, markup) in
if index != 0 {
currentAttrString.append(.singleNewline(withFontSize: newLineFontSize))
}
let attrString = visit(markup)
if let codeBlock = markup as? CodeBlock {
appendCurrentAttrString()
let m = NSMutableAttributedString(attributedString: attrString)
results.append(.init(attributedString: m, resultType: .codeBlock(codeBlock.language)))
currentAttrString = NSMutableAttributedString()
} else if markup.children.contains(where: { $0 is Markdown.Image }) {
markup.children.forEach { mk in
let a = visit(mk)
if let i = mk as? Markdown.Image {
appendCurrentAttrString()
results.append(.init(attributedString: NSMutableAttributedString(attributedString: a), resultType: .image(i.source ?? "")))
currentAttrString = NSMutableAttributedString()
} else {
currentAttrString.append(a)
}
}
} else {
currentAttrString.append(attrString)
}
}
appendCurrentAttrString()
return results
}
mutating public func defaultVisit(_ markup: Markup) -> NSAttributedString {
let paragraph = NSMutableParagraphStyle()
paragraph.lineHeightMultiple = 1.45
let result = NSMutableAttributedString(string: "", attributes: [
.font: NSFont.systemFont(ofSize: 14), .foregroundColor: isDarkMode ? NSColor.HF.gray300 : NSColor.HF.gray700, .paragraphStyle: paragraph
])
for child in markup.children {
result.append(visit(child))
}
return result
}
mutating public func visitText(_ text: Markdown.Text) -> NSAttributedString {
let paragraph = NSMutableParagraphStyle()
paragraph.lineHeightMultiple = 1.45
return NSAttributedString(
string: text.plainText,
attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: isDarkMode ? NSColor.HF.gray300 : NSColor.HF.gray700, .paragraphStyle: paragraph])
}
mutating public func visitEmphasis(_ emphasis: Emphasis) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in emphasis.children {
result.append(visit(child))
}
result.applyEmphasis()
return result
}
mutating public func visitStrong(_ strong: Strong) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in strong.children {
result.append(visit(child))
}
result.applyStrong()
return result
}
mutating public func visitParagraph(_ paragraph: Paragraph) -> NSAttributedString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 1.45
let result = NSMutableAttributedString(string: "", attributes: [.paragraphStyle: paragraphStyle])
for child in paragraph.children {
if child is Markdown.Image {
} else {
result.append(visit(child))
}
}
if paragraph.hasSuccessor {
result.append(
paragraph.isContainedInList
? .singleNewline(withFontSize: newLineFontSize)
: .doubleNewline(withFontSize: newLineFontSize))
}
return result
}
mutating public func visitHeading(_ heading: Heading) -> NSAttributedString {
let result = NSMutableAttributedString()
result.append(.singleNewline(withFontSize: newLineFontSize))
for child in heading.children {
result.append(visit(child))
}
result.applyHeading(withLevel: heading.level)
if heading.hasSuccessor {
result.append(.doubleNewline(withFontSize: newLineFontSize))
}
return result
}
mutating public func visitLink(_ link: Markdown.Link) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in link.children {
result.append(visit(child))
}
let url = link.destination != nil ? URL(string: link.destination!) : nil
result.applyLink(withURL: url)
return result
}
mutating public func visitInlineCode(_ inlineCode: InlineCode) -> NSAttributedString {
return NSAttributedString(
string: "`\(inlineCode.code)`",
attributes: [
.font: NSFont.systemFont(ofSize: 14, weight: .bold),
.foregroundColor: isDarkMode ? NSColor.HF.white : NSColor.HF.gray900,
])
}
public func visitCodeBlock(_ codeBlock: CodeBlock) -> NSAttributedString {
let result = NSMutableAttributedString(
attributedString: highlighter.highlight(codeBlock.code.trimmingCharacters(in: .whitespacesAndNewlines), as: codeBlock.language)
?? NSAttributedString(string: codeBlock.code))
// if codeBlock.hasSuccessor {
// result.append(.singleNewline(withFontSize: newLineFontSize))
// }
return result
}
public func visitImage(_ image: Markdown.Image) -> NSAttributedString {
return NSAttributedString(string: image.source ?? "")
}
mutating public func visitStrikethrough(_ strikethrough: Strikethrough) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in strikethrough.children {
result.append(visit(child))
}
result.applyStrikethrough()
return result
}
mutating public func visitUnorderedList(_ unorderedList: UnorderedList) -> NSAttributedString {
let result = NSMutableAttributedString()
result.append(.singleNewline(withFontSize: newLineFontSize))
let font = NSFont.systemFont(ofSize: baseFontSize, weight: .regular)
for listItem in unorderedList.listItems {
var listItemAttributes: [NSAttributedString.Key: Any] = [:]
let listItemParagraphStyle = NSMutableParagraphStyle()
listItemParagraphStyle.lineHeightMultiple = 1.45
let baseLeftMargin: CGFloat = 0
let leftMarginOffset = baseLeftMargin + (20.0 * CGFloat(unorderedList.listDepth))
let spacingFromIndex: CGFloat = 8.0
let bulletWidth = ceil(
NSAttributedString(string: "•", attributes: [.font: font]).size().width)
let firstTabLocation = leftMarginOffset + bulletWidth
let secondTabLocation = firstTabLocation + spacingFromIndex
listItemParagraphStyle.tabStops = [
NSTextTab(textAlignment: .right, location: firstTabLocation),
NSTextTab(textAlignment: .left, location: secondTabLocation),
]
listItemParagraphStyle.headIndent = secondTabLocation
listItemAttributes[.paragraphStyle] = listItemParagraphStyle
listItemAttributes[.font] = NSFont.systemFont(ofSize: baseFontSize, weight: .regular)
listItemAttributes[.listDepth] = unorderedList.listDepth
let listItemAttributedString =
visit(listItem).mutableCopy() as! NSMutableAttributedString
listItemAttributedString.insert(
NSAttributedString(string: "\t•\t", attributes: listItemAttributes), at: 0)
result.append(listItemAttributedString)
}
if unorderedList.hasSuccessor {
result.append(.doubleNewline(withFontSize: newLineFontSize))
}
return result
}
mutating public func visitListItem(_ listItem: ListItem) -> NSAttributedString {
let result = NSMutableAttributedString(string: "", attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: isDarkMode ? NSColor.HF.gray300 : NSColor.HF.gray700])
for child in listItem.children {
result.append(visit(child))
}
if listItem.hasSuccessor {
result.append(.singleNewline(withFontSize: newLineFontSize))
}
return result
}
mutating public func visitOrderedList(_ orderedList: OrderedList) -> NSAttributedString {
let result = NSMutableAttributedString()
result.append(.singleNewline(withFontSize: newLineFontSize))
for (index, listItem) in orderedList.listItems.enumerated() {
var listItemAttributes: [NSAttributedString.Key: Any] = [:]
let font = NSFont.systemFont(ofSize: 14)
let numeralFont = NSFont.systemFont(ofSize: 14)
let listItemParagraphStyle = NSMutableParagraphStyle()
listItemParagraphStyle.lineHeightMultiple = 1.45
// Implement a base amount to be spaced from the left side at all times to better visually differentiate it as a list
let baseLeftMargin: CGFloat = 0
let leftMarginOffset = baseLeftMargin + (20.0 * CGFloat(orderedList.listDepth))
// Grab the highest number to be displayed and measure its width (yes normally some digits are wider than others but since we're using the numeral mono font all will be the same width in this case)
let highestNumberInList = orderedList.startIndex + UInt(orderedList.childCount)
let numeralColumnWidth = ceil(
NSAttributedString(
string: "\(highestNumberInList).", attributes: [.font: numeralFont]
).size().width)
let spacingFromIndex: CGFloat = 10.0
let firstTabLocation = leftMarginOffset + numeralColumnWidth
let secondTabLocation = firstTabLocation + spacingFromIndex
listItemParagraphStyle.tabStops = [
NSTextTab(textAlignment: .right, location: firstTabLocation),
NSTextTab(textAlignment: .left, location: secondTabLocation),
]
listItemParagraphStyle.headIndent = secondTabLocation
listItemAttributes[.paragraphStyle] = listItemParagraphStyle
listItemAttributes[.font] = font
listItemAttributes[.listDepth] = orderedList.listDepth
let listItemAttributedString =
visit(listItem).mutableCopy() as! NSMutableAttributedString
// Same as the normal list attributes, but for prettiness in formatting we want to use the cool monospaced numeral font
var numberAttributes = listItemAttributes
numberAttributes[.font] = numeralFont
numberAttributes[.foregroundColor] = isDarkMode ? NSColor.HF.gray400 : NSColor.HF.gray500
let numberAttributedString = NSAttributedString(
string: "\t\(UInt(index) + orderedList.startIndex).\t", attributes: numberAttributes)
listItemAttributedString.insert(numberAttributedString, at: 0)
result.append(listItemAttributedString)
}
if orderedList.hasSuccessor {
result.append(
orderedList.isContainedInList
? .singleNewline(withFontSize: newLineFontSize)
: .doubleNewline(withFontSize: newLineFontSize))
}
return result
}
mutating public func visitBlockQuote(_ blockQuote: BlockQuote) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in blockQuote.children {
var quoteAttributes: [NSAttributedString.Key: Any] = [:]
let quoteParagraphStyle = NSMutableParagraphStyle()
let baseLeftMargin: CGFloat = 15.0
let leftMarginOffset = baseLeftMargin + (20.0 * CGFloat(blockQuote.quoteDepth))
quoteParagraphStyle.tabStops = [
NSTextTab(textAlignment: .left, location: leftMarginOffset)
]
quoteParagraphStyle.headIndent = leftMarginOffset
quoteAttributes[.paragraphStyle] = quoteParagraphStyle
quoteAttributes[.font] = NSFont.systemFont(ofSize: baseFontSize, weight: .regular)
quoteAttributes[.listDepth] = blockQuote.quoteDepth
let quoteAttributedString = visit(child).mutableCopy() as! NSMutableAttributedString
quoteAttributedString.insert(
NSAttributedString(string: "\t", attributes: quoteAttributes), at: 0)
quoteAttributedString.addAttribute(.foregroundColor, value: NSColor.systemGray)
result.append(quoteAttributedString)
}
if blockQuote.hasSuccessor {
result.append(.doubleNewline(withFontSize: newLineFontSize))
}
return result
}
}
// MARK: - Extensions Land
extension NSMutableAttributedString {
func applyEmphasis() {
enumerateAttribute(.font, in: NSRange(location: 0, length: length), options: []) {
value, range, stop in
guard let font = value as? NSFont else { return }
let newFont = font.apply(newTraits: .italic)
addAttribute(.font, value: newFont, range: range)
}
}
func applyStrong() {
enumerateAttribute(.font, in: NSRange(location: 0, length: length), options: []) {
value, range, stop in
guard let font = value as? NSFont else { return }
let newFont = font.apply(newTraits: .bold)
addAttribute(.font, value: newFont, range: range)
}
}
func applyLink(withURL url: URL?) {
addAttribute(.foregroundColor, value: NSColor.systemBlue)
if let url = url {
addAttribute(.link, value: url)
}
}
func applyBlockquote() {
addAttribute(.foregroundColor, value: NSColor.systemGray)
}
func applyHeading(withLevel headingLevel: Int) {
enumerateAttribute(.font, in: NSRange(location: 0, length: length), options: []) {
value, range, stop in
guard let font = value as? NSFont else { return }
let newFont = font.apply(
newTraits: .bold, newPointSize: 28.0 - CGFloat(headingLevel * 2))
addAttribute(.font, value: newFont, range: range)
}
}
func applyStrikethrough() {
addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue)
}
}
extension ListItemContainer {
/// Depth of the list if nested within others. Index starts at 0.
var listDepth: Int {
var index = 0
var currentElement = parent
while currentElement != nil {
if currentElement is ListItemContainer {
index += 1
}
currentElement = currentElement?.parent
}
return index
}
}
extension BlockQuote {
/// Depth of the quote if nested within others. Index starts at 0.
var quoteDepth: Int {
var index = 0
var currentElement = parent
while currentElement != nil {
if currentElement is BlockQuote {
index += 1
}
currentElement = currentElement?.parent
}
return index
}
}
extension NSAttributedString.Key {
static let listDepth = NSAttributedString.Key("ListDepth")
static let quoteDepth = NSAttributedString.Key("QuoteDepth")
}
extension NSMutableAttributedString {
func addAttribute(_ name: NSAttributedString.Key, value: Any) {
addAttribute(name, value: value, range: NSRange(location: 0, length: length))
}
func addAttributes(_ attrs: [NSAttributedString.Key: Any]) {
addAttributes(attrs, range: NSRange(location: 0, length: length))
}
}
extension Markup {
/// Returns true if this element has sibling elements after it.
var hasSuccessor: Bool {
guard let childCount = parent?.childCount else { return false }
return indexInParent < childCount - 1
}
var isContainedInList: Bool {
var currentElement = parent
while currentElement != nil {
if currentElement is ListItemContainer {
return true
}
currentElement = currentElement?.parent
}
return false
}
}
extension NSAttributedString {
static func singleNewline(withFontSize fontSize: CGFloat) -> NSAttributedString {
return NSAttributedString(
string: "\n", attributes: [.font: NSFont.systemFont(ofSize: fontSize, weight: .regular)]
)
}
static func doubleNewline(withFontSize fontSize: CGFloat) -> NSAttributedString {
return NSAttributedString(
string: "\n",
attributes: [.font: NSFont.systemFont(ofSize: fontSize, weight: .regular)])
}
}