import UIKit /// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。 /// /// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型, /// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。 /// /// 处理流程: /// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码) /// 2. 按中文章节正则拆分为多个章节 /// 3. 将纯文本包装为 HTML 段落 /// 4. 通过渲染器(DTCoreText)渲染为富文本 /// 5. CoreText 分页 → RDEPUBTextBook public final class RDPlainTextBookBuilder { private let renderer: RDEPUBTextRenderer private let layoutConfig: RDEPUBTextLayoutConfig public init( renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(), layoutConfig: RDEPUBTextLayoutConfig = .default ) { self.renderer = renderer self.layoutConfig = layoutConfig } /// 从纯文本文件构建分页书籍。 /// /// - Parameters: /// - textFileURL: 纯文本文件的本地 URL /// - pageSize: 页面尺寸 /// - style: 渲染样式(字体、行距、颜色) /// - Returns: 分页后的书籍模型 public func build( textFileURL: URL, pageSize: CGSize, style: RDEPUBTextRenderStyle ) throws -> RDEPUBTextBook { let rawText = rd_decodeTextFile(url: textFileURL) let chapterSpecs = splitChapters(from: rawText) var chapters: [RDEPUBTextChapter] = [] var flatPages: [RDEPUBTextPage] = [] for (index, spec) in chapterSpecs.enumerated() { let html = wrapTextAsHTML(spec.content) let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style) let content = NSMutableAttributedString(attributedString: rendered.attributedString) let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : [] let effectiveFrames = layoutFrames.isEmpty && content.length > 0 ? [ RDEPUBTextLayoutFrame( contentRange: NSRange(location: 0, length: content.length), breakReason: .chapterEnd, blockRange: nil, attachmentRanges: [], attachmentKinds: [], blockKinds: [], semanticHints: [], attachmentPlacements: [], trailingFragmentID: nil, diagnostics: [ "page break: chapterEnd", "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" ] ) ] : layoutFrames let href = "chapter_\(index).xhtml" let chapterAttributedContent = content.copy() as! NSAttributedString let pages = effectiveFrames.enumerated().map { localPageIndex, frame in let range = frame.contentRange return RDEPUBTextPage( absolutePageIndex: flatPages.count + localPageIndex, chapterIndex: index, spineIndex: index, href: href, chapterTitle: spec.title ?? "第 \(index + 1) 章", pageIndexInChapter: localPageIndex, totalPagesInChapter: effectiveFrames.count, chapterContent: chapterAttributedContent, content: content.attributedSubstring(from: range), contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + max(range.length - 1, 0), metadata: frame.metadata ) } chapters.append( RDEPUBTextChapter( chapterIndex: index, spineIndex: index, href: href, title: spec.title ?? "第 \(index + 1) 章", attributedContent: chapterAttributedContent, fragmentOffsets: [:], pageBreakReasons: pages.map { $0.metadata.breakReason }, pages: pages ) ) flatPages.append(contentsOf: pages) } return RDEPUBTextBook(chapters: chapters, pages: flatPages) } // MARK: - 章节拆分 /// 章节规格:标题 + 内容 private struct ChapterSpec { let title: String? let content: String } /// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式, /// 无匹配时将整篇文本作为一章处理。 private func splitChapters(from text: String) -> [ChapterSpec] { let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { return [ChapterSpec(title: nil, content: text)] } let nsText = text as NSString let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)) guard !matches.isEmpty else { return [ChapterSpec(title: nil, content: text)] } var specs: [ChapterSpec] = [] for (i, match) in matches.enumerated() { let titleRange = match.range(at: 1) let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines) let contentStart = titleRange.location + titleRange.length let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart) let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines) if !content.isEmpty { specs.append(ChapterSpec(title: title, content: content)) } } // 拆分结果为空(所有章节内容为空),整体作为一章 if specs.isEmpty { return [ChapterSpec(title: nil, content: text)] } return specs } // MARK: - HTML 包装 /// 将纯文本包装为 HTML 段落:每行一个 `
` 标签 private func wrapTextAsHTML(_ text: String) -> String { let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty } let body = paragraphs.map { "
\($0)
" }.joined(separator: "\n") return "\(body)" } // MARK: - 文本解码 /// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码 private func rd_decodeTextFile(url: URL) -> String { if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { return content } if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { return content } if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { return content } return "" } }