189 lines
7.8 KiB
Swift
189 lines
7.8 KiB
Swift
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 request = RDEPUBTextChapterRenderRequest(
|
||
context: RDEPUBTextChapterContext(
|
||
href: "chapter_\(index).xhtml",
|
||
title: spec.title ?? "第 \(index + 1) 章",
|
||
html: html,
|
||
baseURL: nil,
|
||
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
|
||
resourceDiagnostics: []
|
||
),
|
||
style: style,
|
||
pageSize: pageSize,
|
||
layoutConfig: layoutConfig
|
||
)
|
||
let rendered = try renderer.renderChapter(request: request)
|
||
|
||
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 段落:每行一个 `<p>` 标签
|
||
private func wrapTextAsHTML(_ text: String) -> String {
|
||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||
return "<html><body>\(body)</body></html>"
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
}
|