001project_wildgrowth/ios/WildGrowth/WildGrowth/ContentBlockBuilder.swift

375 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.7232.7, lineSpacing10)
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 NSAttributedStringparagraphSpacingAfter 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.5ptspacingAfter
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