Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Styled markdown with AttributedString #3590

Open
wants to merge 37 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1eefa25
Add AttributedString extension for parsing markdown and enabling styl…
laevandus Feb 11, 2025
a777f20
Add tests for AttributedString markdown styling
laevandus Feb 12, 2025
d9c80e8
Use paragraph style for setting correct text alignment in lists
laevandus Feb 12, 2025
fadc96d
Add tests for inline presentation and backslash
laevandus Feb 12, 2025
8c71979
Support custom body text color
laevandus Feb 12, 2025
ea4a555
Remove MarkdownStyles.linkFont because we use UITextView for renderin…
laevandus Feb 12, 2025
923806d
Add CHANGELOG entry
laevandus Feb 12, 2025
f9d581c
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 12, 2025
e6ef338
Document linkFont removal and attributed string parsing
laevandus Feb 13, 2025
43464e0
Fix unit-tests
laevandus Feb 13, 2025
c6d2142
Fix more tests
laevandus Feb 13, 2025
9f8f284
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 13, 2025
ce6e353
Replace public extension with MarkdownParser type
laevandus Feb 13, 2025
501e9d2
Do not make headers bold
laevandus Feb 13, 2025
00f2905
Return nil for presentation intents which are not handled
laevandus Feb 17, 2025
111cb83
Update changelog
laevandus Feb 17, 2025
6314102
Update snapshot tests for backslashes
laevandus Feb 17, 2025
5978c43
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 17, 2025
3d957fc
Basic support for RTL and remove background color from code blocks
laevandus Feb 18, 2025
c70bd5e
Fix test compilation
laevandus Feb 18, 2025
8f3f59c
Use unicode bullet for lists in markdown
laevandus Feb 18, 2025
53809fa
Update changelog about iOS 15
laevandus Feb 18, 2025
9cfe3f1
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 18, 2025
58cd8e6
Be more careful with insertion index and skip parsing if string is empty
laevandus Feb 18, 2025
002f362
Use Fonts for default fonts in markdown
laevandus Feb 18, 2025
ca88af3
Allow appending when inserting
laevandus Feb 18, 2025
81b75aa
Fix a test after character change
laevandus Feb 19, 2025
468c2ed
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 19, 2025
99b5a04
Remove SwiftyMarkdown (#3591)
laevandus Feb 19, 2025
7d8edbb
Add containsMarkdown to MarkdownParser and move related tests. Handle…
laevandus Feb 19, 2025
24cfead
Merge branch 'feature/attributed-string-styled-markdown' of https://g…
laevandus Feb 19, 2025
255a863
Add empty string check
laevandus Feb 19, 2025
eb289f5
Update changelog
laevandus Feb 19, 2025
5d2c5f4
Fix mistake in test
laevandus Feb 19, 2025
65f804e
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 19, 2025
470c972
Fix various issues with newlines and adjustments to formatting
laevandus Feb 21, 2025
6062bd6
Add more snapshot tests
laevandus Feb 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming

## StreamChat
### ✅ Added
- Add `AttributeString.init(markdown:attributes:presentationIntentAttributes:)` for parsing and styling markdown strings [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)
### 🐞 Fixed
- Update channel's preview message when coming back to online [#3574](https://github.com/GetStream/stream-chat-swift/pull/3574)

### StreamChatUI
### 🔄 Changed
- Use `AttributedString` for parsing and rendering markdown instead of SwiftyMarkdown [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)
### 💥 Removed
- Remove `MarkdownStyles.linkFont` [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)

# [4.72.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.72.0)
_February 04, 2025_
Expand Down
137 changes: 137 additions & 0 deletions Sources/StreamChat/Extensions/AttributedString+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

@available(iOS 15, *)
extension AttributedString {
/// Creates an attributed string from a Markdown-formatted string using the provided attributes.
/// - Parameters:
/// - markdown: The string that contains the Markdown formatting.
/// - attributes: The attributes to use for the whole string.
/// - presentationIntentAttributes: The closure for customising attributes for presentation intents. Called for quote, code, list item, and headers.
public init(
markdown: String,
attributes: AttributeContainer,
presentationIntentAttributes: (PresentationIntent.Kind, PresentationIntent) -> AttributeContainer
) throws {
let options = AttributedString.MarkdownParsingOptions(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible,
languageCode: nil
)
var attributedString = try AttributedString(
markdown: markdown,
options: options
)

attributedString.mergeAttributes(attributes)

// Style block based intents
var previousBlockStyling: BlockStyling?

for (presentationIntent, range) in attributedString.runs[\.presentationIntent].reversed() {
guard let presentationIntent else { continue }
var blockStyling = BlockStyling(range: range)

for blockIntentType in presentationIntent.components {
switch blockIntentType.kind {
case .blockQuote:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.prependedString = "| "
blockStyling.succeedingNewlineCount += 1
case .codeBlock:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.precedingNewlineCount += 1
case .header:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.precedingNewlineCount += 1
blockStyling.succeedingNewlineCount += 1
case .paragraph:
blockStyling.precedingNewlineCount += 1
case .listItem(ordinal: let ordinal):
if blockStyling.listItemOrdinal == nil {
blockStyling.listItemOrdinal = ordinal
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
}
case .orderedList:
blockStyling.listId = blockIntentType.identity
if blockStyling.isOrdered == nil {
blockStyling.isOrdered = true
} else {
blockStyling.prependedString.insert("\t", at: blockStyling.prependedString.startIndex)
}
case .unorderedList:
blockStyling.listId = blockIntentType.identity
if blockStyling.isOrdered == nil {
blockStyling.isOrdered = false
} else {
blockStyling.prependedString.insert("\t", at: blockStyling.prependedString.startIndex)
}
case .thematicBreak, .table, .tableHeaderRow, .tableRow, .tableCell:
break
@unknown default:
break
}
}
// Give additional space for just text
if presentationIntent.components.count == 1, presentationIntent.components.allSatisfy({ $0.kind == .paragraph }) {
blockStyling.succeedingNewlineCount += 1
}
// Preparing list items
if let listItemOrdinal = blockStyling.listItemOrdinal {
if blockStyling.isOrdered == true {
blockStyling.prependedString.append("\(listItemOrdinal).\t")
} else {
blockStyling.prependedString.append("•\t")
}
// Extra space when list's last item
if let previousBlockStyling, previousBlockStyling.listId != blockStyling.listId {
blockStyling.succeedingNewlineCount += 1
}
}
// Inserting additional space after the current block (reverse enumeration, therefore use the previous range)
if blockStyling.succeedingNewlineCount > 0, let previousBlockStyling {
let newlineString = String(repeating: "\n", count: blockStyling.succeedingNewlineCount)
let insertedString = AttributedString(newlineString, attributes: attributes)
attributedString.insert(insertedString, at: previousBlockStyling.range.lowerBound)
}
// Additional attributes
if let attributes = blockStyling.mergedAttributes {
attributedString[range].mergeAttributes(attributes)
}
// Inserting additional characters (list items etc)
if !blockStyling.prependedString.isEmpty {
let attributes = attributes.merging(blockStyling.mergedAttributes ?? AttributeContainer())
let insertedString = AttributedString(blockStyling.prependedString, attributes: attributes)
attributedString.insert(insertedString, at: range.lowerBound)
}
// Spacing before the block
if blockStyling.precedingNewlineCount > 0, attributedString.startIndex != range.lowerBound {
let newlineString = String(repeating: "\n", count: blockStyling.precedingNewlineCount)
let attributes = attributes.merging(blockStyling.mergedAttributes ?? AttributeContainer())
let insertedString = AttributedString(newlineString, attributes: attributes)
attributedString.insert(insertedString, at: range.lowerBound)
}

previousBlockStyling = blockStyling
}

self = attributedString
}
}

// Note: newlines are used instead of paragraph style because SwiftUI does render paragraph styles
@available(iOS 15.0, *)
private struct BlockStyling {
let range: Range<AttributedString.Index>
var precedingNewlineCount = 0
var succeedingNewlineCount = 0
var mergedAttributes: AttributeContainer?
var prependedString = ""
var listItemOrdinal: Int?
var listId: Int?
var isOrdered: Bool?
}
154 changes: 126 additions & 28 deletions Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,129 @@ open class DefaultMarkdownFormatter: MarkdownFormatter {
}

open func format(_ string: String) -> NSAttributedString {
let markdownFormatter = SwiftyMarkdown(string: string)
modify(swiftyMarkdownFont: markdownFormatter.code, with: styles.codeFont)
modify(swiftyMarkdownFont: markdownFormatter.body, with: styles.bodyFont)
modify(swiftyMarkdownFont: markdownFormatter.link, with: styles.linkFont)
modify(swiftyMarkdownFont: markdownFormatter.h1, with: styles.h1Font)
modify(swiftyMarkdownFont: markdownFormatter.h2, with: styles.h2Font)
modify(swiftyMarkdownFont: markdownFormatter.h3, with: styles.h3Font)
modify(swiftyMarkdownFont: markdownFormatter.h4, with: styles.h4Font)
modify(swiftyMarkdownFont: markdownFormatter.h5, with: styles.h5Font)
modify(swiftyMarkdownFont: markdownFormatter.h6, with: styles.h6Font)
return markdownFormatter.attributedString()
if #available(iOS 15, *) {
do {
let attributedString = try AttributedString(
markdown: string,
attributes: AttributeContainer(defaultAttributes),
presentationIntentAttributes: presentationIntentAttributes(for:in:)
)
return NSAttributedString(attributedString)
} catch {
log.debug("Failed to parse string for markdown: \(error.localizedDescription)")
}
}
return NSAttributedString(
string: string,
attributes: defaultAttributes
)
}

private func modify(swiftyMarkdownFont: FontProperties, with font: MarkdownFont) {
if let fontName = font.name {
swiftyMarkdownFont.fontName = fontName
// MARK: - Styling Attributes

private var colorPalette: Appearance.ColorPalette { Appearance.default.colorPalette }

private var defaultAttributes: [NSAttributedString.Key: Any] {
[
.font: UIFont.font(forMarkdownFont: styles.bodyFont),
.foregroundColor: styles.bodyFont.color ?? Appearance.default.colorPalette.text
]
}

@available(iOS 15, *)
private func presentationIntentAttributes(for presentationKind: PresentationIntent.Kind, in presentationIntent: PresentationIntent) -> AttributeContainer {
switch presentationKind {
case .blockQuote:
return AttributeContainer([
.foregroundColor: colorPalette.subtitleText
])
case .codeBlock:
var attributes: [NSAttributedString.Key: Any] = [
.backgroundColor: colorPalette.background2,
.font: UIFont.font(forMarkdownFont: styles.codeFont, monospaced: true),
.foregroundColor: styles.codeFont.color
].compactMapValues { $0 }
return AttributeContainer(attributes)
case .header(let level):
let font: UIFont
let foregroundColor: UIColor?
switch level {
case 1:
font = UIFont.font(forMarkdownFont: styles.h1Font, textStyle: .title1, weight: .bold)
foregroundColor = styles.h1Font.color
case 2:
font = UIFont.font(forMarkdownFont: styles.h2Font, textStyle: .title2, weight: .bold)
foregroundColor = styles.h2Font.color
case 3:
font = UIFont.font(forMarkdownFont: styles.h3Font, textStyle: .title3, weight: .bold)
foregroundColor = styles.h3Font.color
case 4:
font = UIFont.font(forMarkdownFont: styles.h4Font, textStyle: .headline, weight: .semibold)
foregroundColor = styles.h4Font.color
case 5:
font = UIFont.font(forMarkdownFont: styles.h5Font, textStyle: .subheadline, weight: .semibold)
foregroundColor = styles.h5Font.color
default:
font = UIFont.font(forMarkdownFont: styles.h6Font, textStyle: .footnote, weight: .semibold)
foregroundColor = styles.h6Font.color ?? colorPalette.subtitleText
}
if let foregroundColor {
return AttributeContainer([.font: font, .foregroundColor: foregroundColor])
} else {
return AttributeContainer([.font: font])
}
case .listItem:
return AttributeContainer([
.paragraphStyle: listItemParagraphStyle(forIndentationLevel: presentationIntent.indentationLevel)
])
default:
return AttributeContainer()
}
if let fontSize = font.size {
swiftyMarkdownFont.fontSize = fontSize
}

// MARK: - Paragraph Styles

private func listItemParagraphStyle(forIndentationLevel level: Int) -> NSParagraphStyle {
let style = NSMutableParagraphStyle()
let location = style.tabStops.first?.location ?? 28
style.headIndent = CGFloat(level) * location
return style
}
}

private extension UIFont {
static func font(
forMarkdownFont markdownFont: MarkdownFont,
textStyle: TextStyle = .body,
weight: Weight? = nil,
monospaced: Bool = false
) -> UIFont {
// Default
var descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
if monospaced, let updatedDescriptor = descriptor.withDesign(.monospaced) {
descriptor = updatedDescriptor
}
if let fontColor = font.color {
swiftyMarkdownFont.color = fontColor
if let weight {
descriptor = descriptor.withWeight(weight)
}
if let fontStyle = font.styling?.asSwiftyMarkdownFontStyle() {
swiftyMarkdownFont.fontStyle = fontStyle
// MarkdownFont
if let fontName = markdownFont.name {
descriptor = descriptor.withFamily(fontName)
}
if let size = markdownFont.size {
descriptor = descriptor.withSize(size)
}
if let traits = markdownFont.styling?.symbolicTraits(), let descriptorWithTraits = descriptor.withSymbolicTraits(traits) {
descriptor = descriptorWithTraits
}
let font = UIFont(descriptor: descriptor, size: descriptor.pointSize)
return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: font)
}
}

private extension UIFontDescriptor {
func withWeight(_ weight: UIFont.Weight) -> UIFontDescriptor {
addingAttributes(([.traits: [UIFontDescriptor.TraitKey.weight: weight]]))
}
}

Expand All @@ -80,9 +177,6 @@ public struct MarkdownStyles {
/// The font used for coding blocks in markdown text.
public var codeFont: MarkdownFont = .init()

/// The font used for links found in markdown text.
public var linkFont: MarkdownFont = .init()

/// The font used for H1 headers in markdown text.
public var h1Font: MarkdownFont = .init()

Expand Down Expand Up @@ -118,6 +212,10 @@ public struct MarkdownFont {
color = nil
styling = nil
}

var hasFontChanges: Bool {
name != nil || size != nil || styling != nil
}
}

public enum MarkdownFontStyle: Int {
Expand All @@ -126,16 +224,16 @@ public enum MarkdownFontStyle: Int {
case italic
case boldItalic

func asSwiftyMarkdownFontStyle() -> FontStyle {
func symbolicTraits() -> UIFontDescriptor.SymbolicTraits? {
switch self {
case .normal:
return .normal
return nil
case .bold:
return .bold
return .traitBold
case .italic:
return .italic
return .traitItalic
case .boldItalic:
return .boldItalic
return [UIFontDescriptor.SymbolicTraits.traitBold, UIFontDescriptor.SymbolicTraits.traitItalic]
}
}
}
Loading
Loading