375 lines
17 KiB
Swift
375 lines
17 KiB
Swift
import UIKit
|
||
import Foundation
|
||
|
||
/// 内容块构建器
|
||
/// 职责:将 [ContentBlock] 数组转换为单个 NSAttributedString
|
||
struct ContentBlockBuilder {
|
||
|
||
// MARK: - 样式常量(与 VerticalScreenPlayerView 保持一致)
|
||
|
||
private static let bodyFontSize: CGFloat = 19 // ✅ 优化:17 → 19,提升阅读舒适度
|
||
private static let bodyLineSpacing: CGFloat = 10 // ✅ 优化:调整为黄金比例 1.72倍行高 (19*1.72≈32.7, lineSpacing≈10)
|
||
private static let paragraphSpacing: CGFloat = 24 // ✅ 优化:22 → 24,段落间距随正文字号增大
|
||
|
||
// MARK: - 核心方法
|
||
|
||
/// 将 ContentBlock 数组转换为单个 NSAttributedString(向后兼容)
|
||
/// - Parameters:
|
||
/// - blocks: ContentBlock 数组
|
||
/// - accentColor: 高亮色(与进度条主题色一致),nil 时用 brandVital
|
||
/// - Returns: 合并后的 NSAttributedString
|
||
static func buildAttributedString(from blocks: [ContentBlock], accentColor: UIColor? = nil) -> NSAttributedString {
|
||
let result = NSMutableAttributedString()
|
||
appendBlocks(blocks, to: result, accentColor: accentColor)
|
||
return result
|
||
}
|
||
|
||
/// 将 ContentBlock 数组转换为单个 NSAttributedString(包含头部信息)
|
||
///
|
||
/// 【极简主义重构说明 - 给 Gemini】
|
||
/// 本次重构移除了头部冗余信息,实现极简阅读体验:
|
||
/// 1. 移除问候语:"作者,你好" 文案已删除
|
||
/// 2. 移除简介:headerInfo.intro 不再显示
|
||
/// 3. 只保留标题:标题样式调整为紧凑布局(paragraphSpacing = 24, paragraphSpacingBefore = 10)
|
||
/// 4. 标题去重:如果正文第一个 block 是标题且内容相同,自动跳过避免重复
|
||
///
|
||
/// 注意:标题保留在 NSAttributedString 中,确保笔记的 NSRange 索引依然有效
|
||
///
|
||
/// - Parameters:
|
||
/// - blocks: ContentBlock 数组
|
||
/// - headerInfo: 课程详情(用于构建标题)
|
||
/// - accentColor: 高亮色(与进度条主题色一致),nil 时用 brandVital
|
||
/// - Returns: 合并后的 NSAttributedString(只包含标题和正文)
|
||
static func buildAttributedString(
|
||
from blocks: [ContentBlock],
|
||
headerInfo: LessonDetail,
|
||
accentColor: UIColor? = nil
|
||
) -> NSAttributedString {
|
||
let result = NSMutableAttributedString()
|
||
|
||
// ==========================================
|
||
// 1. 头部构建(极简版:只保留标题)
|
||
// ==========================================
|
||
|
||
// 主标题 (30pt Heavy, 醒目) ✅ 优化:28 → 30,随正文字号调整
|
||
let titleAttrs: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 30, weight: .heavy), // ✅ 优化:28 → 30
|
||
.foregroundColor: UIColor.inkPrimary,
|
||
.paragraphStyle: {
|
||
let style = NSMutableParagraphStyle()
|
||
style.lineHeightMultiple = 1.1
|
||
style.paragraphSpacing = 36 // ✅ 优化:32 → 36,标题与正文间距加大
|
||
style.paragraphSpacingBefore = 10
|
||
style.alignment = .left
|
||
return style
|
||
}(),
|
||
.kern: 0.5
|
||
]
|
||
result.append(NSAttributedString(string: headerInfo.title + "\n", attributes: titleAttrs))
|
||
|
||
// ❌ 已移除:问候语和简介("作者,你好" 和 intro 文案)
|
||
|
||
// ==========================================
|
||
// 2. 正文构建
|
||
// ==========================================
|
||
|
||
// ✅ 标题去重逻辑:防止正文第一个 block 重复显示标题
|
||
var blocksToAppend = blocks
|
||
if !blocks.isEmpty,
|
||
let firstBlock = blocks.first,
|
||
firstBlock.type == .heading1 {
|
||
let firstBlockContent = firstBlock.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let headerTitle = headerInfo.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// 精确匹配:如果第一个 block 是标题且内容相同,则跳过
|
||
if firstBlockContent == headerTitle {
|
||
blocksToAppend = Array(blocks.dropFirst())
|
||
}
|
||
}
|
||
|
||
appendBlocks(blocksToAppend, to: result, accentColor: accentColor)
|
||
|
||
return result
|
||
}
|
||
|
||
// MARK: - Private Helpers
|
||
|
||
/// 复用块拼接逻辑:用「段后间距」保证段间松(paragraphSpacingAfter 比段前+换行更稳定)
|
||
private static func appendBlocks(_ blocks: [ContentBlock], to result: NSMutableAttributedString, accentColor: UIColor? = nil) {
|
||
for (index, block) in blocks.enumerated() {
|
||
let isLastBlock = (index == blocks.count - 1)
|
||
let spacingAfter: CGFloat? = isLastBlock ? nil : paragraphSpacing
|
||
let blockContent = buildBlockContent(block, accentColor: accentColor, paragraphSpacingAfter: spacingAfter)
|
||
result.append(blockContent)
|
||
if !isLastBlock {
|
||
result.append(NSAttributedString(string: "\n", attributes: [:]))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Block 内容构建
|
||
|
||
/// 根据 Block 类型构建 NSAttributedString;paragraphSpacingAfter 用于段间松(仅对 paragraph 等生效)
|
||
/// 正文内:### / #### / ## 直接删掉不做样式;行首 - 或 - 后直接跟字 统一成 •
|
||
private static func buildBlockContent(_ block: ContentBlock, accentColor: UIColor? = nil, paragraphSpacingAfter: CGFloat? = nil) -> NSAttributedString {
|
||
switch block.type {
|
||
case .heading1:
|
||
return buildHeading1(block.content)
|
||
case .heading2:
|
||
return buildHeading2(block.content)
|
||
case .paragraph:
|
||
let raw = block.content
|
||
let withoutHeading = stripMarkdownHeadingPrefix(raw)
|
||
let contentWithBullets = replaceLineStartHyphenWithBullet(withoutHeading)
|
||
return buildParagraph(contentWithBullets, accentColor: accentColor, spacingAfter: paragraphSpacingAfter)
|
||
case .quote:
|
||
return buildQuote(block.content)
|
||
case .highlight:
|
||
return buildHighlight(block.content, accentColor: accentColor)
|
||
case .list:
|
||
return buildList(block.content)
|
||
}
|
||
}
|
||
|
||
// MARK: - 各类型 Block 的构建方法
|
||
|
||
/// Heading1: 26pt bold, 上下间距 28pt/10pt ✅ 优化:24 → 26
|
||
private static func buildHeading1(_ content: String) -> NSAttributedString {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.paragraphSpacingBefore = 28
|
||
paragraphStyle.paragraphSpacing = 10
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 26, weight: .bold), // ✅ 优化:24 → 26
|
||
.foregroundColor: UIColor.inkPrimary,
|
||
.paragraphStyle: paragraphStyle
|
||
]
|
||
|
||
return NSAttributedString(string: content, attributes: attributes)
|
||
}
|
||
|
||
/// Heading2: 22pt bold, 上下间距 20pt/8pt ✅ 优化:20 → 22
|
||
private static func buildHeading2(_ content: String) -> NSAttributedString {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.paragraphSpacingBefore = 20
|
||
paragraphStyle.paragraphSpacing = 8
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 22, weight: .bold), // ✅ 优化:20 → 22
|
||
.foregroundColor: UIColor.inkPrimary,
|
||
.paragraphStyle: paragraphStyle
|
||
]
|
||
|
||
return NSAttributedString(string: content, attributes: attributes)
|
||
}
|
||
|
||
/// Paragraph: 17pt, 行高 29.3pt (1.72倍), 字间距 0.5pt;spacingAfter 为段后间距(段间松)
|
||
private static func buildParagraph(_ content: String, accentColor: UIColor? = nil, spacingAfter: CGFloat? = nil) -> NSAttributedString {
|
||
return parseHTMLContent(content, baseAttributes: getParagraphAttributes(spacingAfter: spacingAfter), accentColor: accentColor)
|
||
}
|
||
|
||
/// 去掉段首的 #### / ### / ## / # 前缀,不改变样式(直接删掉)
|
||
private static func stripMarkdownHeadingPrefix(_ content: String) -> String {
|
||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.hasPrefix("#### ") {
|
||
return String(trimmed.dropFirst(5))
|
||
}
|
||
if trimmed.hasPrefix("### ") {
|
||
return String(trimmed.dropFirst(4))
|
||
}
|
||
if trimmed.hasPrefix("## ") {
|
||
return String(trimmed.dropFirst(3))
|
||
}
|
||
if trimmed.hasPrefix("# ") {
|
||
return String(trimmed.dropFirst(2))
|
||
}
|
||
return content
|
||
}
|
||
|
||
/// 将正文中「行首 - 」或「行首 - 后直接跟字」统一成「• 」,用于支持 AI 输出的 Markdown 列表
|
||
private static func replaceLineStartHyphenWithBullet(_ content: String) -> String {
|
||
let lines = content.components(separatedBy: "\n")
|
||
let processed = lines.map { line -> String in
|
||
let dropCount = line.prefix(while: { $0.isWhitespace }).count
|
||
let rest = line.dropFirst(dropCount)
|
||
if rest.hasPrefix("- ") {
|
||
let indent = String(line.prefix(dropCount))
|
||
return indent + "• " + rest.dropFirst(2)
|
||
}
|
||
if rest.hasPrefix("-") {
|
||
let indent = String(line.prefix(dropCount))
|
||
if rest.count == 1 {
|
||
return indent + "• "
|
||
}
|
||
if !(rest.dropFirst(1).first?.isWhitespace ?? true) {
|
||
return indent + "• " + rest.dropFirst(1)
|
||
}
|
||
}
|
||
return line
|
||
}
|
||
return processed.joined(separator: "\n")
|
||
}
|
||
|
||
/// Quote: 17pt medium, 左侧竖线, 灰色文字, 行距 8pt
|
||
private static func buildQuote(_ content: String) -> NSAttributedString {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineSpacing = 8
|
||
paragraphStyle.firstLineHeadIndent = 12 // 左侧留出竖线空间
|
||
paragraphStyle.headIndent = 12
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: bodyFontSize, weight: .medium),
|
||
.foregroundColor: UIColor.inkSecondary,
|
||
.paragraphStyle: paragraphStyle
|
||
]
|
||
|
||
// TODO: 左侧竖线可以通过 NSTextAttachment 或自定义绘制实现
|
||
// 这里先用文本缩进模拟
|
||
let quoteContent = "│ " + content // 临时方案:使用字符模拟竖线
|
||
return NSAttributedString(string: quoteContent, attributes: attributes)
|
||
}
|
||
|
||
/// Highlight: 20pt bold, 背景色, 圆角, 内边距 ✅ 优化:18 → 20
|
||
private static func buildHighlight(_ content: String, accentColor: UIColor? = nil) -> NSAttributedString {
|
||
let color = accentColor ?? UIColor.brandVital
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineSpacing = bodyLineSpacing + 2
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 20, weight: .bold), // ✅ 优化:18 → 20
|
||
.foregroundColor: color,
|
||
.paragraphStyle: paragraphStyle,
|
||
.backgroundColor: color.withAlphaComponent(0.08)
|
||
]
|
||
|
||
return NSAttributedString(string: content, attributes: attributes)
|
||
}
|
||
|
||
/// List: 项目符号 "• ", 17pt
|
||
private static func buildList(_ content: String) -> NSAttributedString {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.firstLineHeadIndent = 0
|
||
paragraphStyle.headIndent = 20 // 项目符号后的缩进
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: bodyFontSize),
|
||
.foregroundColor: UIColor.inkBody,
|
||
.paragraphStyle: paragraphStyle,
|
||
.kern: 0.5
|
||
]
|
||
|
||
let listContent = "• " + content
|
||
return NSAttributedString(string: listContent, attributes: attributes)
|
||
}
|
||
|
||
// MARK: - 样式辅助方法
|
||
|
||
/// 获取段落基础样式;spacingAfter 为段后间距(段间松),nil 表示不额外加
|
||
private static func getParagraphAttributes(spacingAfter: CGFloat? = nil) -> [NSAttributedString.Key: Any] {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineBreakMode = .byWordWrapping
|
||
if let after = spacingAfter {
|
||
paragraphStyle.paragraphSpacing = after
|
||
}
|
||
|
||
let font = UIFont.systemFont(ofSize: bodyFontSize)
|
||
let lineSpacing: CGFloat = bodyLineSpacing
|
||
|
||
let baseLineHeight = font.lineHeight
|
||
paragraphStyle.minimumLineHeight = baseLineHeight + lineSpacing
|
||
paragraphStyle.maximumLineHeight = baseLineHeight + lineSpacing
|
||
paragraphStyle.lineSpacing = 0
|
||
|
||
return [
|
||
.font: font,
|
||
.foregroundColor: UIColor.inkBody,
|
||
.paragraphStyle: paragraphStyle,
|
||
.kern: 0.5
|
||
]
|
||
}
|
||
|
||
/// 解析 HTML 内容(处理 <span class='highlight'>、<b>、<br> 标签)
|
||
private static func parseHTMLContent(_ rawString: String, baseAttributes: [NSAttributedString.Key: Any], accentColor: UIColor? = nil) -> NSAttributedString {
|
||
let cleanString = rawString.replacingOccurrences(of: "<br>", with: "\n")
|
||
let result = NSMutableAttributedString()
|
||
|
||
let highlightColor = accentColor ?? UIColor.brandVital
|
||
var highlightAttributes = baseAttributes
|
||
highlightAttributes[.foregroundColor] = highlightColor
|
||
highlightAttributes[.font] = UIFont.systemFont(ofSize: bodyFontSize, weight: .bold)
|
||
|
||
// 分割 <span class='highlight'>
|
||
let parts = cleanString.components(separatedBy: "<span class='highlight'>")
|
||
|
||
for (index, part) in parts.enumerated() {
|
||
if index == 0 {
|
||
// 开头普通文本(含 <b> 需解析)
|
||
result.append(parseBoldTags(part, baseAttributes: baseAttributes))
|
||
} else {
|
||
let subParts = part.components(separatedBy: "</span>")
|
||
if !subParts.isEmpty {
|
||
// 高亮部分(可能内含 <b>,解析后统一为高亮样式即可,主要去掉 <b> 字面量)
|
||
result.append(parseBoldTags(subParts[0], baseAttributes: highlightAttributes))
|
||
|
||
// 剩余普通部分(含 <b> 需解析)
|
||
if subParts.count > 1 {
|
||
result.append(parseBoldTags(subParts[1], baseAttributes: baseAttributes))
|
||
}
|
||
} else {
|
||
result.append(parseBoldTags(part, baseAttributes: baseAttributes))
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/// 解析 <b>...</b>,将加粗区间应用粗体,其余用 baseAttributes;未闭合的 <b> 视作到文末加粗,多余 </b> 舍弃。
|
||
private static func parseBoldTags(_ s: String, baseAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString {
|
||
let res = NSMutableAttributedString()
|
||
let baseFont = baseAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: bodyFontSize)
|
||
var boldAttrs = baseAttributes
|
||
boldAttrs[.font] = UIFont.systemFont(ofSize: baseFont.pointSize, weight: .bold)
|
||
|
||
let openTag = "<b>"
|
||
let closeTag = "</b>"
|
||
var i = s.startIndex
|
||
|
||
while i < s.endIndex {
|
||
if let openRange = s.range(of: openTag, range: i..<s.endIndex) {
|
||
// <b> 前的普通文本
|
||
res.append(NSAttributedString(string: String(s[i..<openRange.lowerBound]), attributes: baseAttributes))
|
||
let afterOpen = openRange.upperBound
|
||
if let closeRange = s.range(of: closeTag, range: afterOpen..<s.endIndex) {
|
||
res.append(NSAttributedString(string: String(s[afterOpen..<closeRange.lowerBound]), attributes: boldAttrs))
|
||
i = closeRange.upperBound
|
||
} else {
|
||
// 未闭合 <b>,到文末为粗体
|
||
res.append(NSAttributedString(string: String(s[afterOpen...]), attributes: boldAttrs))
|
||
i = s.endIndex
|
||
}
|
||
} else {
|
||
// 无 <b>:若有孤儿 </b> 则跳过,其余为普通
|
||
if let closeRange = s.range(of: closeTag, range: i..<s.endIndex) {
|
||
res.append(NSAttributedString(string: String(s[i..<closeRange.lowerBound]), attributes: baseAttributes))
|
||
i = closeRange.upperBound
|
||
} else {
|
||
res.append(NSAttributedString(string: String(s[i...]), attributes: baseAttributes))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
/// 创建段落样式(用于段落间距)
|
||
private static func createParagraphStyle(spacingBefore: CGFloat) -> NSParagraphStyle {
|
||
let style = NSMutableParagraphStyle()
|
||
style.paragraphSpacingBefore = spacingBefore
|
||
return style
|
||
}
|
||
}
|
||
|
||
// MARK: - 注意
|
||
// UIColor 扩展已在 DesignSystem+UIKit.swift 中定义,无需重复定义
|