|
| 1 | +// |
| 2 | +// STMarkdownInlineCodeBlockRenderer.swift |
| 3 | +// STBaseProject |
| 4 | +// |
| 5 | + |
| 6 | +import UIKit |
| 7 | + |
| 8 | +/// 代码块内联文本渲染器。 |
| 9 | +/// |
| 10 | +/// 将代码块渲染为带背景色的 `NSAttributedString`(非 `NSTextAttachment` 图片), |
| 11 | +/// 适用于两个场景: |
| 12 | +/// |
| 13 | +/// - **流式阶段**:每帧逐字追加,避免 O(N²) 全帧图片重绘; |
| 14 | +/// 启用 `tailHighlightOnly` 后仅对末尾 `tailHighlightLines` 行做语法高亮, |
| 15 | +/// 把每帧扫描量压至常数 O(1)。 |
| 16 | +/// |
| 17 | +/// - **AST 静态阶段**:携带 `.codeBlockMarker` 自定义属性供 overlay 按钮定位, |
| 18 | +/// 避免 TextKit 1 大尺寸图片 glyph rect 计算不准确导致代码块覆盖后续正文。 |
| 19 | +/// |
| 20 | +/// 两种用途通过 `Mode` 控制,差异仅在头部属性字典和是否截断高亮行数。 |
| 21 | +public enum STMarkdownInlineCodeBlockRenderMode { |
| 22 | + /// 流式阶段:跳过业务自定义头部属性,启用尾部高亮截断。 |
| 23 | + case streaming(tailHighlightLines: Int) |
| 24 | + /// AST 静态阶段:可附加业务自定义的头部属性(如宿主的 `.codeBlockMarker`)。 |
| 25 | + case ast(extraHeaderAttributes: [NSAttributedString.Key: Any]) |
| 26 | +} |
| 27 | + |
| 28 | +public struct STMarkdownInlineCodeBlockRenderer { |
| 29 | + |
| 30 | + public init() {} |
| 31 | + |
| 32 | + /// 渲染代码块为带背景色的 `NSAttributedString`。 |
| 33 | + /// |
| 34 | + /// - Parameters: |
| 35 | + /// - language: 代码语言(nil / 空字符串时显示 "CODE") |
| 36 | + /// - code: 代码正文 |
| 37 | + /// - style: 当前 Markdown 样式配置 |
| 38 | + /// - mode: 渲染模式(流式 or AST) |
| 39 | + /// - skipFadeInKey: 流式 shimmer 文字跳过淡入动画的 `NSAttributedString.Key`(传 nil 时不写入) |
| 40 | + public func render( |
| 41 | + language: String?, |
| 42 | + code: String, |
| 43 | + style: STMarkdownStyle, |
| 44 | + mode: STMarkdownInlineCodeBlockRenderMode, |
| 45 | + skipFadeInKey: NSAttributedString.Key? = nil |
| 46 | + ) -> NSAttributedString { |
| 47 | + let contentInsets = style.codeBlockContentInsets |
| 48 | + let backgroundColor = style.codeBlockBackgroundColor ?? UIColor.secondarySystemBackground |
| 49 | + let headerColor = style.codeBlockHeaderTextColor ?? style.textColor.withAlphaComponent(0.72) |
| 50 | + let headerFont = UIFont.st_monospacedSystemFont( |
| 51 | + ofSize: max(style.font.pointSize - 2, 12), |
| 52 | + weight: .semibold |
| 53 | + ) |
| 54 | + let codeFont = UIFont.st_monospacedSystemFont( |
| 55 | + ofSize: max(style.font.pointSize - 1, 12), |
| 56 | + weight: .regular |
| 57 | + ) |
| 58 | + |
| 59 | + let result = NSMutableAttributedString() |
| 60 | + |
| 61 | + // 头部段落样式:spacer attachment 撑出左侧间距,确保背景色连续 |
| 62 | + let headerParagraphStyle = NSMutableParagraphStyle() |
| 63 | + headerParagraphStyle.tailIndent = -contentInsets.right |
| 64 | + headerParagraphStyle.paragraphSpacingBefore = contentInsets.top |
| 65 | + headerParagraphStyle.lineBreakMode = .byTruncatingTail |
| 66 | + if style.codeBlockHeaderHeight > 0 { |
| 67 | + headerParagraphStyle.minimumLineHeight = style.codeBlockHeaderHeight |
| 68 | + headerParagraphStyle.maximumLineHeight = style.codeBlockHeaderHeight |
| 69 | + } |
| 70 | + |
| 71 | + let headerText = language.flatMap { $0.isEmpty ? nil : $0.uppercased() } ?? "CODE" |
| 72 | + let headerBaselineOffset: CGFloat = style.codeBlockHeaderHeight > 0 |
| 73 | + ? (style.codeBlockHeaderHeight - headerFont.lineHeight) / 2 |
| 74 | + : 0 |
| 75 | + |
| 76 | + var headerAttrs: [NSAttributedString.Key: Any] = [ |
| 77 | + .font: headerFont, |
| 78 | + .foregroundColor: headerColor, |
| 79 | + .backgroundColor: backgroundColor, |
| 80 | + .paragraphStyle: headerParagraphStyle, |
| 81 | + .baselineOffset: headerBaselineOffset, |
| 82 | + ] |
| 83 | + if let key = skipFadeInKey { headerAttrs[key] = true } |
| 84 | + |
| 85 | + // AST 模式写入宿主注入的业务属性(如 .codeBlockMarker) |
| 86 | + if case .ast(let extraAttrs) = mode { |
| 87 | + for (key, value) in extraAttrs { |
| 88 | + headerAttrs[key] = value |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + let headerLine = NSMutableAttributedString() |
| 93 | + let spacer = NSTextAttachment() |
| 94 | + spacer.image = UIImage() |
| 95 | + spacer.bounds = CGRect(x: 0, y: 0, width: contentInsets.left, height: 1) |
| 96 | + let spacerStr = NSMutableAttributedString(attachment: spacer) |
| 97 | + spacerStr.addAttributes(headerAttrs, range: NSRange(location: 0, length: spacerStr.length)) |
| 98 | + headerLine.append(spacerStr) |
| 99 | + headerLine.append(NSAttributedString(string: headerText + "\n", attributes: headerAttrs)) |
| 100 | + result.append(headerLine) |
| 101 | + |
| 102 | + // 代码体段落样式 |
| 103 | + let codeParagraphStyle = NSMutableParagraphStyle() |
| 104 | + codeParagraphStyle.lineBreakMode = .byCharWrapping |
| 105 | + codeParagraphStyle.lineSpacing = max(style.bodyLineSpacing, 2) |
| 106 | + codeParagraphStyle.firstLineHeadIndent = contentInsets.left |
| 107 | + codeParagraphStyle.headIndent = contentInsets.left |
| 108 | + codeParagraphStyle.tailIndent = -contentInsets.right |
| 109 | + codeParagraphStyle.paragraphSpacingBefore = style.codeBlockSeparatorSpacing |
| 110 | + |
| 111 | + // 语法高亮(流式阶段可截断为尾部 N 行降低 O(N²)) |
| 112 | + let mutableBody: NSMutableAttributedString |
| 113 | + if case .streaming(let tailLines) = mode, |
| 114 | + tailLines > 0 { |
| 115 | + let codeLines = code.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) |
| 116 | + let totalLines = codeLines.count |
| 117 | + if totalLines > tailLines { |
| 118 | + let prefixCode = codeLines.prefix(totalLines - tailLines).joined(separator: "\n") + "\n" |
| 119 | + let suffixCode = codeLines.dropFirst(totalLines - tailLines).joined(separator: "\n") |
| 120 | + mutableBody = NSMutableAttributedString() |
| 121 | + mutableBody.append(NSAttributedString(string: prefixCode, attributes: [ |
| 122 | + .font: codeFont, |
| 123 | + .foregroundColor: style.codeBlockTextColor ?? style.textColor, |
| 124 | + .paragraphStyle: codeParagraphStyle, |
| 125 | + ])) |
| 126 | + let highlightedSuffix = STMarkdownCodeSyntaxHighlighter.highlightedBody( |
| 127 | + language: language, |
| 128 | + code: suffixCode, |
| 129 | + font: codeFont, |
| 130 | + textColor: style.codeBlockTextColor ?? style.textColor, |
| 131 | + paragraphStyle: codeParagraphStyle |
| 132 | + ) |
| 133 | + mutableBody.append(highlightedSuffix) |
| 134 | + } else { |
| 135 | + mutableBody = NSMutableAttributedString( |
| 136 | + attributedString: STMarkdownCodeSyntaxHighlighter.highlightedBody( |
| 137 | + language: language, |
| 138 | + code: code, |
| 139 | + font: codeFont, |
| 140 | + textColor: style.codeBlockTextColor ?? style.textColor, |
| 141 | + paragraphStyle: codeParagraphStyle |
| 142 | + ) |
| 143 | + ) |
| 144 | + } |
| 145 | + } else { |
| 146 | + mutableBody = NSMutableAttributedString( |
| 147 | + attributedString: STMarkdownCodeSyntaxHighlighter.highlightedBody( |
| 148 | + language: language, |
| 149 | + code: code, |
| 150 | + font: codeFont, |
| 151 | + textColor: style.codeBlockTextColor ?? style.textColor, |
| 152 | + paragraphStyle: codeParagraphStyle |
| 153 | + ) |
| 154 | + ) |
| 155 | + } |
| 156 | + mutableBody.addAttribute( |
| 157 | + .backgroundColor, |
| 158 | + value: backgroundColor, |
| 159 | + range: NSRange(location: 0, length: mutableBody.length) |
| 160 | + ) |
| 161 | + result.append(mutableBody) |
| 162 | + |
| 163 | + // 尾部:底部间距 |
| 164 | + let tailParagraphStyle = NSMutableParagraphStyle() |
| 165 | + tailParagraphStyle.minimumLineHeight = contentInsets.bottom |
| 166 | + tailParagraphStyle.maximumLineHeight = contentInsets.bottom |
| 167 | + var tailAttrs: [NSAttributedString.Key: Any] = [ |
| 168 | + .font: codeFont, |
| 169 | + .foregroundColor: UIColor.clear, |
| 170 | + .backgroundColor: backgroundColor, |
| 171 | + .paragraphStyle: tailParagraphStyle, |
| 172 | + ] |
| 173 | + if let key = skipFadeInKey, case .streaming = mode { tailAttrs[key] = true } |
| 174 | + result.append(NSAttributedString(string: "\n", attributes: tailAttrs)) |
| 175 | + |
| 176 | + return result |
| 177 | + } |
| 178 | +} |
0 commit comments