@@ -2,10 +2,11 @@ import Foundation
22import Markdown
33
44/// One renderable block of a chat message. Prose stays on the
5- /// AttributedString pipeline; fenced code and GFM tables get dedicated views.
5+ /// AttributedString pipeline; fenced code, display math, and GFM tables get dedicated views.
66enum ChatMarkdownBlock : Equatable {
77 case prose( String )
88 case code( ChatCodeBlock )
9+ case math( ChatMathBlock )
910 case table( ChatMarkdownTable )
1011}
1112
@@ -17,6 +18,12 @@ struct ChatCodeBlock: Equatable {
1718 let isComplete : Bool
1819}
1920
21+ struct ChatMathBlock : Equatable {
22+ let latex : String
23+ /// True when the delimiter was closed or the message finished streaming.
24+ let isComplete : Bool
25+ }
26+
2027struct ChatMarkdownTable : Equatable {
2128 enum ColumnAlignment : Equatable {
2229 case leading
@@ -50,45 +57,45 @@ enum ChatMarkdownBlockSyntax {
5057}
5158
5259enum ChatMarkdownBlockSegmenter {
60+ static let maxMathBytes = 5000
5361 static let maxTableBytes = 20000
5462 static let maxTableRows = 100
5563 static let maxTableColumns = 12
5664 static let maxTableCells = 600
5765
58- /// Extracts only top-level fenced code and GFM tables. The parser owns
66+ /// Extracts only top-level fenced code, display math, and GFM tables. The parser owns
5967 /// CommonMark container and reference semantics; nested blocks stay in the
6068 /// surrounding prose range unchanged.
6169 static func segments( markdown: String , isComplete: Bool ) -> [ ChatMarkdownBlock ] {
6270 let source = SourceBuffer ( markdown)
6371 let document = Document ( parsing: source. markdown)
64- var blocks : [ ChatMarkdownBlock ] = [ ]
65- var proseStart = 0
66-
67- func appendProse( until end: Int ) {
68- guard proseStart < end else { return }
69- blocks. append ( contentsOf: self . proseOnly ( Array ( source. lines [ proseStart..< end] ) ) )
70- }
72+ let mathResult = self . mathExtractions (
73+ source: source,
74+ document: document,
75+ isComplete: isComplete)
76+ var extractions = mathResult. extractions
7177
7278 for child in document. children {
73- guard let lineRange = source. lineRange ( for: child. range) , lineRange. lowerBound >= proseStart else {
79+ guard let lineRange = source. lineRange ( for: child. range) else { continue }
80+ if mathResult. protectedRanges. contains ( where: { $0. contains ( lineRange. lowerBound) } ) {
7481 continue
7582 }
7683
7784 if let code = child as? Markdown . CodeBlock ,
7885 let opener = FenceOpener . parse ( source. lines [ lineRange. lowerBound] )
7986 {
80- appendProse ( until: lineRange. lowerBound)
8187 let language = code. language?
8288 . split ( whereSeparator: \. isWhitespace)
8389 . first
8490 . map { $0. lowercased ( ) }
8591 let closed = lineRange. count > 1
8692 && opener. isClose ( source. lines [ lineRange. index ( before: lineRange. endIndex) ] )
87- blocks. append ( . code( ChatCodeBlock (
88- language: language,
89- code: self . dropStructuralCodeNewline ( code. code) ,
90- isComplete: closed || isComplete) ) )
91- proseStart = lineRange. upperBound
93+ extractions. append ( Extraction (
94+ lineRange: lineRange,
95+ block: . code( ChatCodeBlock (
96+ language: language,
97+ code: self . dropStructuralCodeNewline ( code. code) ,
98+ isComplete: closed || isComplete) ) ) )
9299 continue
93100 }
94101
@@ -105,10 +112,29 @@ enum ChatMarkdownBlockSegmenter {
105112 {
106113 continue
107114 }
108- appendProse ( until: tableRange. lowerBound)
109- blocks. append ( . table( rendered) )
110- proseStart = tableRange. upperBound
115+ extractions. append ( Extraction ( lineRange: tableRange, block: . table( rendered) ) )
116+ }
117+ }
118+
119+ extractions. sort { left, right in
120+ if left. lineRange. lowerBound != right. lineRange. lowerBound {
121+ return left. lineRange. lowerBound < right. lineRange. lowerBound
111122 }
123+ return left. lineRange. upperBound > right. lineRange. upperBound
124+ }
125+
126+ var blocks : [ ChatMarkdownBlock ] = [ ]
127+ var proseStart = 0
128+
129+ func appendProse( until end: Int ) {
130+ guard proseStart < end else { return }
131+ blocks. append ( contentsOf: self . proseOnly ( Array ( source. lines [ proseStart..< end] ) ) )
132+ }
133+
134+ for extraction in extractions where extraction. lineRange. lowerBound >= proseStart {
135+ appendProse ( until: extraction. lineRange. lowerBound)
136+ blocks. append ( extraction. block)
137+ proseStart = extraction. lineRange. upperBound
112138 }
113139
114140 appendProse ( until: source. lines. count)
@@ -118,6 +144,89 @@ enum ChatMarkdownBlockSegmenter {
118144 return blocks
119145 }
120146
147+ private static func mathExtractions(
148+ source: SourceBuffer ,
149+ document: Document ,
150+ isComplete: Bool ) -> MathExtractionResult
151+ {
152+ guard source. markdown. contains ( " $$ " ) || source. markdown. contains ( #"\["# ) else {
153+ return MathExtractionResult ( extractions: [ ] , protectedRanges: [ ] )
154+ }
155+ var topLevelParagraphLines = Set < Int > ( )
156+ var inlineCodeLines = Set < Int > ( )
157+ func collectInlineCodeLines( from markup: any Markup ) {
158+ if markup is Markdown . InlineCode , let lineRange = source. lineRange ( for: markup. range) {
159+ inlineCodeLines. formUnion ( lineRange)
160+ }
161+ for child in markup. children {
162+ collectInlineCodeLines ( from: child)
163+ }
164+ }
165+ for child in document. children where child is Markdown . Paragraph {
166+ if let lineRange = source. lineRange ( for: child. range) {
167+ topLevelParagraphLines. formUnion ( lineRange)
168+ }
169+ collectInlineCodeLines ( from: child)
170+ }
171+ var extractions : [ Extraction ] = [ ]
172+ var protectedRanges : [ Range < Int > ] = [ ]
173+ var lineIndex = 0
174+
175+ while lineIndex < source. lines. count {
176+ guard topLevelParagraphLines. contains ( lineIndex) ,
177+ !inlineCodeLines. contains ( lineIndex) ,
178+ let opener = MathDelimiter . parse ( source. lines [ lineIndex] )
179+ else {
180+ lineIndex += 1
181+ continue
182+ }
183+
184+ if let sameLineLatex = opener. sameLineLatex {
185+ let lineRange = lineIndex..< ( lineIndex + 1 )
186+ if sameLineLatex. utf8. count <= self . maxMathBytes {
187+ extractions. append ( Extraction (
188+ lineRange: lineRange,
189+ block: . math( ChatMathBlock ( latex: sameLineLatex, isComplete: true ) ) ) )
190+ } else {
191+ protectedRanges. append ( lineRange)
192+ }
193+ lineIndex += 1
194+ continue
195+ }
196+
197+ let contentStart = lineIndex + 1
198+ var closeIndex = contentStart
199+ while closeIndex < source. lines. count,
200+ !opener. isClose ( source. lines [ closeIndex] )
201+ {
202+ closeIndex += 1
203+ }
204+
205+ let closed = closeIndex < source. lines. count
206+ guard closed || isComplete else {
207+ // The first unmatched opener owns the remaining stream. Stop
208+ // here so later opener-looking lines do not trigger rescans.
209+ protectedRanges. append ( lineIndex..< source. lines. count)
210+ return MathExtractionResult ( extractions: extractions, protectedRanges: protectedRanges)
211+ }
212+
213+ let contentEnd = closed ? closeIndex : source. lines. count
214+ let lineRange = lineIndex..< ( closed ? closeIndex + 1 : source. lines. count)
215+ let latex = source. lines [ contentStart..< contentEnd]
216+ . joined ( separator: " \n " )
217+ . trimmingCharacters ( in: . whitespacesAndNewlines)
218+ if latex. utf8. count <= self . maxMathBytes {
219+ extractions. append ( Extraction (
220+ lineRange: lineRange,
221+ block: . math( ChatMathBlock ( latex: latex, isComplete: closed || isComplete) ) ) )
222+ } else {
223+ protectedRanges. append ( lineRange)
224+ }
225+ lineIndex = lineRange. upperBound
226+ }
227+ return MathExtractionResult ( extractions: extractions, protectedRanges: protectedRanges)
228+ }
229+
121230 private static func proseOnly( _ lines: [ String ] ) -> [ ChatMarkdownBlock ] {
122231 // Boundary blank lines only separate extracted blocks; the rendered
123232 // VStack provides that spacing. Interior blanks remain paragraphs.
@@ -150,6 +259,52 @@ enum ChatMarkdownBlockSegmenter {
150259 code. hasSuffix ( " \n " ) ? String ( code. dropLast ( ) ) : code
151260 }
152261
262+ private struct Extraction {
263+ let lineRange : Range < Int >
264+ let block : ChatMarkdownBlock
265+ }
266+
267+ private struct MathExtractionResult {
268+ let extractions : [ Extraction ]
269+ /// Rejected math stays prose and owns any block-looking syntax inside its span.
270+ let protectedRanges : [ Range < Int > ]
271+ }
272+
273+ private struct MathDelimiter {
274+ let close : String
275+ let sameLineLatex : String ?
276+
277+ static func parse( _ line: String ) -> MathDelimiter ? {
278+ let ( indent, afterIndent) = FenceOpener . leadingSpaces ( of: line)
279+ guard indent <= 3 , afterIndent < line. endIndex else { return nil }
280+ let suffix = line [ afterIndent... ]
281+ let pair : ( open: String , close: String )
282+ if suffix. hasPrefix ( " $$ " ) {
283+ pair = ( " $$ " , " $$ " )
284+ } else if suffix. hasPrefix ( #"\["# ) {
285+ pair = ( #"\["# , #"\]"# )
286+ } else {
287+ return nil
288+ }
289+
290+ let contentStart = suffix. index ( suffix. startIndex, offsetBy: pair. open. count)
291+ let remainder = suffix [ contentStart... ]
292+ if remainder. trimmingCharacters ( in: . whitespaces) . isEmpty {
293+ return MathDelimiter ( close: pair. close, sameLineLatex: nil )
294+ }
295+ guard let closeRange = remainder. range ( of: pair. close, options: . backwards) ,
296+ remainder [ closeRange. upperBound... ] . trimmingCharacters ( in: . whitespaces) . isEmpty
297+ else { return nil }
298+ let latex = remainder [ ..< closeRange. lowerBound]
299+ . trimmingCharacters ( in: . whitespacesAndNewlines)
300+ return MathDelimiter ( close: pair. close, sameLineLatex: latex)
301+ }
302+
303+ func isClose( _ line: String ) -> Bool {
304+ line. trimmingCharacters ( in: . whitespaces) == self . close
305+ }
306+ }
307+
153308 private static func table(
154309 _ table: Markdown . Table ,
155310 source: SourceBuffer ,
@@ -324,7 +479,7 @@ enum ChatMarkdownBlockSegmenter {
324479 return count >= self . count && line [ cursor... ] . allSatisfy ( \. isWhitespace)
325480 }
326481
327- private static func leadingSpaces( of line: String ) -> ( count: Int , end: String . Index ) {
482+ fileprivate static func leadingSpaces( of line: String ) -> ( count: Int , end: String . Index ) {
328483 var count = 0
329484 var cursor = line. startIndex
330485 while cursor < line. endIndex, line [ cursor] == " " {
0 commit comments