import Foundation import SwiftMath import SwiftUI #if os(macOS) import AppKit typealias ChatInlineMathPlatformImage = NSImage #else import UIKit typealias ChatInlineMathPlatformImage = UIImage #endif struct ChatInlineMathSpan { let latex: String let source: String } enum ChatInlineMathScanner { enum Piece: Equatable { case markdown(String) case math(latex: String, source: String) case literal(String) } static let maxSpanCount = 16 static let maxSourceBytes = 200 static func pieces(in markdown: String) -> [Piece] { guard markdown.contains(#"\("#) else { return [.markdown(markdown)] } var pieces: [Piece] = [] var textStart = markdown.startIndex var cursor = markdown.startIndex var spanCount = 0 let codeSpans = self.confirmedCodeSpans(in: markdown) var codeSpanIndex = 0 while cursor < markdown.endIndex { if codeSpanIndex < codeSpans.count, cursor == codeSpans[codeSpanIndex].lowerBound { cursor = codeSpans[codeSpanIndex].upperBound codeSpanIndex += 1 continue } guard markdown[cursor...].hasPrefix(#"\("#), !self.isEscaped(at: cursor, in: markdown) else { cursor = markdown.index(after: cursor) continue } if textStart < cursor { pieces.append(.markdown(String(markdown[textStart..]) -> Candidate? { var cursor = start var codeSpanIndex = self.firstCodeSpan(endingAfter: start, in: codeSpans) var containsNewline = false while cursor < markdown.endIndex { if codeSpanIndex < codeSpans.count, cursor == codeSpans[codeSpanIndex].lowerBound { cursor = codeSpans[codeSpanIndex].upperBound codeSpanIndex += 1 continue } let character = markdown[cursor] if character == "\n" || character == "\r" { containsNewline = true } if markdown[cursor...].hasPrefix(#"\)"#), !self.isEscaped(at: cursor, in: markdown) { return Candidate( closeStart: cursor, end: markdown.index(cursor, offsetBy: 2), containsNewline: containsNewline) } cursor = markdown.index(after: cursor) } return nil } private struct BacktickRun { let start: String.Index let end: String.Index let length: Int let canOpen: Bool } private static func confirmedCodeSpans(in markdown: String) -> [Range] { var runs: [BacktickRun] = [] var cursor = markdown.startIndex while cursor < markdown.endIndex { guard markdown[cursor] == "`" else { cursor = markdown.index(after: cursor) continue } let end = self.endOfBacktickRun(at: cursor, in: markdown) runs.append(BacktickRun( start: cursor, end: end, length: markdown.distance(from: cursor, to: end), canOpen: !self.isEscaped(at: cursor, in: markdown))) cursor = end } var nextMatchingRun = [Int?](repeating: nil, count: runs.count) var nextIndexByLength: [Int: Int] = [:] for index in runs.indices.reversed() { nextMatchingRun[index] = nextIndexByLength[runs[index].length] nextIndexByLength[runs[index].length] = index } var spans: [Range] = [] var index = 0 while index < runs.count { guard runs[index].canOpen, let closeIndex = nextMatchingRun[index] else { index += 1 continue } spans.append(runs[index].start..]) -> Int { var lower = 0 var upper = spans.count while lower < upper { let middle = lower + (upper - lower) / 2 if spans[middle].upperBound <= index { lower = middle + 1 } else { upper = middle } } return lower } private static func endOfBacktickRun(at start: String.Index, in markdown: String) -> String.Index { var end = start while end < markdown.endIndex, markdown[end] == "`" { end = markdown.index(after: end) } return end } private static func isEscaped(at index: String.Index, in markdown: String) -> Bool { var cursor = index var slashCount = 0 while cursor > markdown.startIndex { let previous = markdown.index(before: cursor) guard markdown[previous] == "\\" else { break } slashCount += 1 cursor = previous } return slashCount.isMultiple(of: 2) == false } } /// Parsed math is stable after its delimiter closes. A bounded cache avoids /// repeating SwiftMath parsing as later streaming deltas rerender old blocks. @MainActor enum ChatMathParseCache { private enum Result { case parsed(MTMathList) case invalid } private static var cache: [String: Result] = [:] private static let capacity = 80 private static let maxNestingDepth = 64 private static let maxCommandCount = 128 private static let unsafeCommands = [#"\color"#, #"\colorbox"#, #"\textcolor"#] static func mathList(latex: String) -> MTMathList? { guard !latex.isEmpty else { return nil } // SwiftMath silently drops unsupported Unicode instead of reporting a // parse error. Preserve the source through the raw-text fallback. guard latex.unicodeScalars.allSatisfy(\.isASCII) else { return nil } // SwiftMath recursively parses groups. Bound hostile nesting before // entering the dependency so a short expression cannot exhaust stack. guard self.isWithinParserLimits(latex) else { return nil } // SwiftMath 1.7.3 traps while typesetting empty color-command bodies. // Chat owns the surrounding color, so preserve these as raw source. guard !self.unsafeCommands.contains(where: latex.contains) else { return nil } if let hit = self.cache[latex] { if case let .parsed(mathList) = hit { return mathList } return nil } let result = MTMathListBuilder.build(fromString: latex) .map(Result.parsed) ?? .invalid if self.cache.count >= self.capacity { self.cache.removeAll(keepingCapacity: true) } self.cache[latex] = result if case let .parsed(mathList) = result { return mathList } return nil } private static func isWithinParserLimits(_ latex: String) -> Bool { var depth = 0 var commandCount = 0 var escaped = false for character in latex { if escaped { escaped = false continue } if character == "\\" { commandCount += 1 if commandCount > self.maxCommandCount { return false } escaped = true } else if character == "{" { depth += 1 if depth > self.maxNestingDepth { return false } } else if character == "}" { depth = max(0, depth - 1) } } return true } } @MainActor enum ChatInlineMathImageCache { struct RenderedImage { let image: ChatInlineMathPlatformImage let baselineOffset: CGFloat } private struct Key: Hashable { let latex: String let fontSize: CGFloat let colorHash: Int } private static var cache: [Key: RenderedImage] = [:] private static let capacity = 80 private static let maxRenderedPixelArea: CGFloat = 262_144 private static let maxRenderedPixelDimension: CGFloat = 4096 static func image( latex: String, fontSize: CGFloat, textColor: Color, colorScheme: ColorScheme) -> RenderedImage? { guard fontSize.isFinite, fontSize > 0, let mathList = ChatMathParseCache.mathList(latex: latex) else { return nil } let platformColor = self.platformColor(textColor, colorScheme: colorScheme) let key = Key( latex: latex, fontSize: fontSize, colorHash: self.colorHash(platformColor)) if let cached = self.cache[key] { return cached } let metricsLabel = MTMathUILabel() metricsLabel.mathList = mathList metricsLabel.labelMode = .text metricsLabel.fontSize = fontSize metricsLabel.textColor = platformColor let size: CGSize #if os(macOS) size = metricsLabel.fittingSize metricsLabel.frame = CGRect(origin: .zero, size: size) metricsLabel.layoutSubtreeIfNeeded() #else size = metricsLabel.intrinsicContentSize metricsLabel.frame = CGRect(origin: .zero, size: size) metricsLabel.layoutIfNeeded() #endif guard self.isSafeImageSize(size, scale: self.imageScale) else { return nil } // MTMathImage allocates immediately in asImage(). Measure with the same // text-mode typesetter first so hostile input cannot request an empty or // oversized backing bitmap. let imageRenderer = MTMathImage( latex: latex, fontSize: fontSize, textColor: platformColor, labelMode: .text, textAlignment: .left) guard let image = imageRenderer.asImage().1 else { return nil } // Text(Image) places the image bottom on the surrounding baseline. // SwiftMath exposes its baseline descent through displayList, so lower // the image by that descent to align the two typographic baselines. let rendered = RenderedImage( image: image, baselineOffset: -(metricsLabel.displayList?.descent ?? 0)) if self.cache.count >= self.capacity { self.cache.removeAll(keepingCapacity: true) } self.cache[key] = rendered return rendered } static func isSafeImageSize(_ size: CGSize, scale: CGFloat) -> Bool { guard size.width.isFinite, size.height.isFinite, scale.isFinite, size.width > 0, size.height > 0, scale > 0 else { return false } let pixelWidth = size.width * scale let pixelHeight = size.height * scale return pixelWidth.isFinite && pixelHeight.isFinite && pixelWidth <= self.maxRenderedPixelDimension && pixelHeight <= self.maxRenderedPixelDimension && pixelWidth * pixelHeight <= self.maxRenderedPixelArea } private static var imageScale: CGFloat { #if os(macOS) NSScreen.main?.backingScaleFactor ?? 2 #else UIScreen.main.scale #endif } private static func platformColor(_ color: Color, colorScheme: ColorScheme) -> MTColor { #if os(macOS) guard let appearance = NSAppearance( named: colorScheme == .dark ? .darkAqua : .aqua) else { return NSColor(color) } var platformColor = NSColor.clear appearance.performAsCurrentDrawingAppearance { let resolved = NSColor(color) platformColor = resolved.usingColorSpace(.deviceRGB) ?? resolved } return platformColor #else let style: UIUserInterfaceStyle = colorScheme == .dark ? .dark : .light return UIColor(color).resolvedColor(with: UITraitCollection(userInterfaceStyle: style)) #endif } private static func colorHash(_ color: MTColor) -> Int { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) var hasher = Hasher() hasher.combine(red) hasher.combine(green) hasher.combine(blue) hasher.combine(alpha) return hasher.finalize() } }