mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 13:34:01 +00:00
414 lines
14 KiB
Swift
414 lines
14 KiB
Swift
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..<cursor])))
|
|
}
|
|
let opener = cursor
|
|
let contentStart = markdown.index(cursor, offsetBy: 2)
|
|
guard let candidate = self.candidate(
|
|
startingAt: contentStart,
|
|
in: markdown,
|
|
codeSpans: codeSpans)
|
|
else {
|
|
pieces.append(.literal(String(markdown[opener...])))
|
|
return pieces
|
|
}
|
|
|
|
spanCount += 1
|
|
let source = String(markdown[opener..<candidate.end])
|
|
let latex = String(markdown[contentStart..<candidate.closeStart])
|
|
if spanCount <= self.maxSpanCount,
|
|
!candidate.containsNewline,
|
|
source.utf8.count <= self.maxSourceBytes
|
|
{
|
|
pieces.append(.math(latex: latex, source: source))
|
|
} else {
|
|
pieces.append(.literal(source))
|
|
}
|
|
cursor = candidate.end
|
|
textStart = cursor
|
|
}
|
|
|
|
if textStart < markdown.endIndex {
|
|
pieces.append(.markdown(String(markdown[textStart...])))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
private struct Candidate {
|
|
let closeStart: String.Index
|
|
let end: String.Index
|
|
let containsNewline: Bool
|
|
}
|
|
|
|
private static func candidate(
|
|
startingAt start: String.Index,
|
|
in markdown: String,
|
|
codeSpans: [Range<String.Index>]) -> 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<String.Index>] {
|
|
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<String.Index>] = []
|
|
var index = 0
|
|
while index < runs.count {
|
|
guard runs[index].canOpen,
|
|
let closeIndex = nextMatchingRun[index]
|
|
else {
|
|
index += 1
|
|
continue
|
|
}
|
|
spans.append(runs[index].start..<runs[closeIndex].end)
|
|
index = closeIndex + 1
|
|
}
|
|
return spans
|
|
}
|
|
|
|
private static func firstCodeSpan(
|
|
endingAfter index: String.Index,
|
|
in spans: [Range<String.Index>]) -> 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()
|
|
}
|
|
}
|