@@ -21,8 +21,9 @@ struct ChatMarkdownRenderer: View {
2121
2222 var body : some View {
2323 let processed = ChatMarkdownPreprocessor . preprocess ( markdown: self . text)
24+ let renderMarkdown = ChatMarkdownDisplayPreprocessor . preserveChatSoftBreaks ( in: processed. cleaned)
2425 VStack ( alignment: . leading, spacing: 10 ) {
25- Text ( self . markdownText ( processed . cleaned ) )
26+ Text ( self . markdownText ( renderMarkdown ) )
2627 . font ( self . font)
2728 . foregroundStyle ( self . textColor)
2829 . tint ( self . linkColor)
@@ -47,6 +48,174 @@ struct ChatMarkdownRenderer: View {
4748 }
4849}
4950
51+ enum ChatMarkdownDisplayPreprocessor {
52+ static func preserveChatSoftBreaks( in markdown: String ) -> String {
53+ let normalized = markdown. replacingOccurrences ( of: " \r \n " , with: " \n " )
54+ let lines = normalized. split ( separator: " \n " , omittingEmptySubsequences: false ) . map ( String . init)
55+ guard lines. count > 1 else { return normalized }
56+
57+ var output = " "
58+ var fence : Fence ?
59+ let tableRows = self . tableRowIndices ( in: lines)
60+
61+ for index in lines. indices {
62+ let line = lines [ index]
63+ let wasInFence = fence != nil
64+ let fenceBoundary = self . fenceBoundary ( in: line, activeFence: fence)
65+ if case let . open( nextFence) = fenceBoundary {
66+ fence = nextFence
67+ } else if case . close = fenceBoundary {
68+ fence = nil
69+ }
70+
71+ output += line
72+
73+ guard index < lines. index ( before: lines. endIndex) else {
74+ continue
75+ }
76+
77+ let nextLine = lines [ lines. index ( after: index) ]
78+ let nextIndex = lines. index ( after: index)
79+ if self . shouldPreserveSoftBreak (
80+ after: line,
81+ before: nextLine,
82+ inTable: tableRows. contains ( index) || tableRows. contains ( nextIndex) ,
83+ inFence: wasInFence,
84+ fenceBoundary: fenceBoundary)
85+ {
86+ output += " \n "
87+ } else {
88+ output += " \n "
89+ }
90+ }
91+
92+ return output
93+ }
94+
95+ private enum FenceBoundary {
96+ case none
97+ case open( Fence )
98+ case close
99+ }
100+
101+ private struct Fence {
102+ let character : Character
103+ let count : Int
104+ let hasOnlyTrailingWhitespace : Bool
105+ }
106+
107+ private static func shouldPreserveSoftBreak(
108+ after line: String ,
109+ before nextLine: String ,
110+ inTable: Bool ,
111+ inFence: Bool ,
112+ fenceBoundary: FenceBoundary ) -> Bool
113+ {
114+ guard !inTable else { return false }
115+ guard !inFence else { return false }
116+ guard case . none = fenceBoundary else { return false }
117+
118+ let trimmed = line. trimmingCharacters ( in: . whitespacesAndNewlines)
119+ let nextTrimmed = nextLine. trimmingCharacters ( in: . whitespacesAndNewlines)
120+ guard !trimmed. isEmpty, !nextTrimmed. isEmpty else { return false }
121+ guard !self . hasMarkdownHardBreak ( line) else { return false }
122+ guard !self . isBlockMarkdownLine ( line) , !self . isBlockMarkdownLine ( nextLine) else { return false }
123+ return true
124+ }
125+
126+ private static func hasMarkdownHardBreak( _ line: String ) -> Bool {
127+ line. hasSuffix ( " \\ " ) || line. hasSuffix ( " " )
128+ }
129+
130+ private static func isBlockMarkdownLine( _ line: String ) -> Bool {
131+ let trimmed = line. trimmingCharacters ( in: . whitespacesAndNewlines)
132+ guard !trimmed. isEmpty else { return false }
133+
134+ return self . matches ( line, #"^\s{0,3}#{1,6}(\s|$)"# )
135+ || self . matches ( line, #"^\s{0,3}>"# )
136+ || self . matches ( line, #"^\s{0,3}([-+*])\s+"# )
137+ || self . matches ( line, #"^\s{0,3}\d{1,9}[.)]\s+"# )
138+ || self . matches ( line, #"^( {4}|\t)"# )
139+ || self . matches ( line, #"^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,}|={3,})$"# )
140+ }
141+
142+ private static func tableRowIndices( in lines: [ String ] ) -> Set < Int > {
143+ var indices = Set < Int > ( )
144+ for index in lines. indices where index > lines. startIndex {
145+ guard self . isTableDelimiterLine ( lines [ index] ) , lines [ lines. index ( before: index) ] . contains ( " | " ) else {
146+ continue
147+ }
148+
149+ indices. insert ( lines. index ( before: index) )
150+ indices. insert ( index)
151+
152+ var cursor = lines. index ( after: index)
153+ while cursor < lines. endIndex, lines [ cursor] . contains ( " | " ) {
154+ indices. insert ( cursor)
155+ cursor = lines. index ( after: cursor)
156+ }
157+ }
158+ return indices
159+ }
160+
161+ private static func isTableDelimiterLine( _ line: String ) -> Bool {
162+ self . matches ( line, #"^\s{0,3}\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$"# )
163+ }
164+
165+ private static func fenceBoundary( in line: String , activeFence: Fence ? ) -> FenceBoundary {
166+ guard let candidate = self . fenceCandidate ( in: line) else {
167+ return . none
168+ }
169+
170+ guard let activeFence else {
171+ return . open( candidate)
172+ }
173+
174+ if candidate. character == activeFence. character,
175+ candidate. count >= activeFence. count,
176+ candidate. hasOnlyTrailingWhitespace
177+ {
178+ return . close
179+ }
180+ return . none
181+ }
182+
183+ private static func fenceCandidate( in line: String ) -> Fence ? {
184+ var cursor = line. startIndex
185+ var spaces = 0
186+ while cursor < line. endIndex, line [ cursor] == " " , spaces < 4 {
187+ spaces += 1
188+ cursor = line. index ( after: cursor)
189+ }
190+ guard spaces <= 3 , cursor < line. endIndex else {
191+ return nil
192+ }
193+
194+ let character = line [ cursor]
195+ guard character == " ` " || character == " ~ " else {
196+ return nil
197+ }
198+
199+ var count = 0
200+ while cursor < line. endIndex, line [ cursor] == character {
201+ count += 1
202+ cursor = line. index ( after: cursor)
203+ }
204+ guard count >= 3 else {
205+ return nil
206+ }
207+ let trailing = line [ cursor... ]
208+ return Fence (
209+ character: character,
210+ count: count,
211+ hasOnlyTrailingWhitespace: trailing. allSatisfy ( \. isWhitespace) )
212+ }
213+
214+ private static func matches( _ line: String , _ pattern: String ) -> Bool {
215+ line. range ( of: pattern, options: . regularExpression) != nil
216+ }
217+ }
218+
50219@MainActor
51220private struct InlineImageList : View {
52221 let images : [ ChatMarkdownPreprocessor . InlineImage ]
0 commit comments