- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
375 lines
15 KiB
Swift
375 lines
15 KiB
Swift
//
|
||
// RDURLReaderController.swift
|
||
// RDReaderDemo
|
||
//
|
||
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
|
||
// 该文件是 ReadViewSDK 的最简使用入口:
|
||
// - .epub 文件:直接创建 RDEPUBReaderController
|
||
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
|
||
// - 分页失败时回退到纯文本 UITextView 展示
|
||
//
|
||
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
|
||
//
|
||
|
||
import UIKit
|
||
|
||
/// URL 阅读器入口控制器
|
||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||
///
|
||
/// 使用方式:
|
||
/// ```swift
|
||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||
/// navigationController?.pushViewController(controller, animated: true)
|
||
/// ```
|
||
public final class RDURLReaderController: UIViewController {
|
||
private struct PendingDemoPageRequest {
|
||
let pageNumber: Int
|
||
let animated: Bool
|
||
var attemptCount: Int
|
||
}
|
||
|
||
/// 书籍文件 URL
|
||
private let bookURL: URL
|
||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||
/// 内嵌的阅读器控制器
|
||
private var embeddedController: UIViewController?
|
||
private let demoStateLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.accessibilityIdentifier = "demo.reader.state"
|
||
label.font = .systemFont(ofSize: 1)
|
||
label.textColor = .clear
|
||
label.alpha = 0.01
|
||
label.isAccessibilityElement = true
|
||
label.text = "reader=opening"
|
||
return label
|
||
}()
|
||
private var pendingDemoPageRequest: PendingDemoPageRequest?
|
||
private var isRetryingPendingDemoPage = false
|
||
private let maxPendingDemoPageAttempts = 24
|
||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||
|
||
/// 初始化方法
|
||
/// - Parameters:
|
||
/// - bookURL: 书籍文件的本地 URL
|
||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||
public init(
|
||
bookURL: URL,
|
||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||
) {
|
||
self.bookURL = bookURL
|
||
self.epubConfiguration = epubConfiguration
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .systemBackground
|
||
title = bookURL.deletingPathExtension().lastPathComponent
|
||
embedReaderController()
|
||
installDemoStateLabel()
|
||
}
|
||
|
||
public override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
emitDemoState()
|
||
}
|
||
|
||
/// 切换阅读器的翻页模式(Demo 用)
|
||
/// - Parameter displayType: 目标翻页模式
|
||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||
readerController?.configuration.displayType = displayType
|
||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||
}
|
||
|
||
/// 跳转到指定页码(Demo 用)
|
||
/// - Parameters:
|
||
/// - pageNumber: 目标页码
|
||
/// - animated: 是否动画过渡
|
||
/// - Returns: 是否成功跳转
|
||
@discardableResult
|
||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||
if moved {
|
||
pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
} else {
|
||
queuePendingDemoPageNavigation(pageNumber, animated: animated)
|
||
}
|
||
return moved
|
||
}
|
||
|
||
/// 执行翻页模式切换序列(Demo 用)
|
||
/// 按照指定顺序和延迟依次切换翻页模式
|
||
/// - Parameters:
|
||
/// - displayTypes: 翻页模式数组
|
||
/// - initialPageNumber: 可选的初始跳转页码
|
||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||
public func runDemoDisplaySequence(
|
||
_ displayTypes: [RDReaderView.DisplayType],
|
||
initialPageNumber: Int? = nil,
|
||
stepDelay: TimeInterval = 1.0
|
||
) {
|
||
let normalizedDelay = max(stepDelay, 0.1)
|
||
if let initialPageNumber {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in
|
||
_ = self?.goToDemoPage(initialPageNumber)
|
||
}
|
||
}
|
||
|
||
for (index, displayType) in displayTypes.enumerated() {
|
||
let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1))
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
self?.applyDemoDisplayType(displayType)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 嵌入阅读器控制器到当前视图层级
|
||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||
private func embedReaderController() {
|
||
let controller: UIViewController
|
||
if bookURL.pathExtension.lowercased() == "epub" {
|
||
controller = RDEPUBReaderController(
|
||
epubURL: bookURL,
|
||
configuration: epubConfiguration
|
||
)
|
||
} else {
|
||
let bookIdentifier = bookURL.lastPathComponent
|
||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||
let pageSize = currentTextPageSize()
|
||
let renderStyle = currentTextRenderStyle()
|
||
let builder = RDPlainTextBookBuilder(
|
||
layoutConfig: RDEPUBTextLayoutConfig(
|
||
frameWidth: pageSize.width,
|
||
frameHeight: pageSize.height,
|
||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
||
numberOfColumns: 1,
|
||
columnGap: 20,
|
||
avoidOrphans: true,
|
||
avoidWidows: true,
|
||
avoidPageBreakInsideEnabled: true,
|
||
hyphenation: true,
|
||
imageMaxHeightRatio: 0.85
|
||
)
|
||
)
|
||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||
controller = RDEPUBReaderController(
|
||
textBook: textBook,
|
||
bookIdentifier: bookIdentifier,
|
||
title: bookTitle,
|
||
textFileURL: bookURL,
|
||
configuration: epubConfiguration
|
||
)
|
||
} else {
|
||
// 分页失败时回退到纯文本展示
|
||
let fallback = UIViewController()
|
||
let textView = UITextView()
|
||
textView.isEditable = false
|
||
textView.text = rd_decodeTextFile(url: bookURL)
|
||
fallback.view = textView
|
||
controller = fallback
|
||
}
|
||
}
|
||
embeddedController = controller
|
||
addChild(controller)
|
||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(controller.view)
|
||
NSLayoutConstraint.activate([
|
||
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
])
|
||
controller.didMove(toParent: self)
|
||
readerController?.delegate = self
|
||
emitDemoState()
|
||
}
|
||
|
||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||
private var readerController: RDEPUBReaderController? {
|
||
embeddedController as? RDEPUBReaderController
|
||
}
|
||
|
||
@discardableResult
|
||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||
guard let readerController else { return false }
|
||
guard pageNumber > 0 else { return false }
|
||
|
||
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
|
||
let numPages = readerController.readerView.numberOfPages()
|
||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||
guard pageNumber <= totalPages else { return false }
|
||
|
||
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
|
||
emitDemoState(prefix: "page=\(pageNumber)")
|
||
return true
|
||
}
|
||
|
||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||
if let pendingDemoPageRequest,
|
||
pendingDemoPageRequest.pageNumber == pageNumber,
|
||
pendingDemoPageRequest.animated == animated {
|
||
return
|
||
}
|
||
|
||
pendingDemoPageRequest = PendingDemoPageRequest(
|
||
pageNumber: pageNumber,
|
||
animated: animated,
|
||
attemptCount: 0
|
||
)
|
||
retryPendingDemoPageIfNeeded()
|
||
}
|
||
|
||
private func retryPendingDemoPageIfNeeded() {
|
||
guard !isRetryingPendingDemoPage else { return }
|
||
guard pendingDemoPageRequest != nil else { return }
|
||
|
||
isRetryingPendingDemoPage = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||
self?.attemptPendingDemoPageNavigation()
|
||
}
|
||
}
|
||
|
||
private func attemptPendingDemoPageNavigation() {
|
||
guard var pendingDemoPageRequest else {
|
||
isRetryingPendingDemoPage = false
|
||
return
|
||
}
|
||
|
||
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
|
||
self.pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
return
|
||
}
|
||
|
||
pendingDemoPageRequest.attemptCount += 1
|
||
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
|
||
self.pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
|
||
return
|
||
}
|
||
|
||
self.pendingDemoPageRequest = pendingDemoPageRequest
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||
self?.attemptPendingDemoPageNavigation()
|
||
}
|
||
}
|
||
|
||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||
private func logDemoState(prefix: String) {
|
||
guard let readerController else { return }
|
||
let location = readerController.currentLocation
|
||
let href = location?.href ?? "nil"
|
||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||
let page = readerController.currentPageNumber.map(String.init) ?? "nil"
|
||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||
}
|
||
|
||
private func installDemoStateLabel() {
|
||
view.addSubview(demoStateLabel)
|
||
demoStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor),
|
||
demoStateLabel.widthAnchor.constraint(equalToConstant: 1),
|
||
demoStateLabel.heightAnchor.constraint(equalToConstant: 1)
|
||
])
|
||
}
|
||
|
||
private func emitDemoState(prefix: String? = nil) {
|
||
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
|
||
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
|
||
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
|
||
let highlights = readerController?.highlights.count ?? 0
|
||
let selection = readerController?.currentSelection == nil ? 0 : 1
|
||
let state = "reader=opened page=\(page) display=\(display) toolbar=\(toolbar) highlights=\(highlights) selection=\(selection)"
|
||
demoStateLabel.text = state
|
||
if let prefix {
|
||
logDemoState(prefix: prefix)
|
||
} else {
|
||
print("[ReadViewDemo] automation \(state)")
|
||
}
|
||
}
|
||
|
||
/// 计算文本分页的页面尺寸。
|
||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
||
/// 以保持与页面展示时的内容几何一致。
|
||
private func currentTextPageSize() -> CGSize {
|
||
UIScreen.main.bounds.size
|
||
}
|
||
|
||
/// 根据当前配置生成文本渲染样式
|
||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
|
||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||
return RDEPUBTextRenderStyle(
|
||
font: font,
|
||
lineSpacing: lineSpacing,
|
||
textColor: epubConfiguration.theme.contentTextColor,
|
||
backgroundColor: epubConfiguration.theme.contentBackgroundColor
|
||
)
|
||
}
|
||
|
||
/// 解码文本文件内容
|
||
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
|
||
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 ""
|
||
}
|
||
}
|
||
|
||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
|
||
retryPendingDemoPageIfNeeded()
|
||
emitDemoState()
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||
retryPendingDemoPageIfNeeded()
|
||
emitDemoState()
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {
|
||
emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)")
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {
|
||
emitDemoState(prefix: "highlights=\(highlights.count)")
|
||
}
|
||
}
|
||
|
||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||
private extension RDReaderView.DisplayType {
|
||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||
var demoArgumentValue: String {
|
||
switch self {
|
||
case .pageCurl:
|
||
return "pageCurl"
|
||
case .horizontalScroll:
|
||
return "horizontalScroll"
|
||
case .verticalScroll:
|
||
return "verticalScroll"
|
||
}
|
||
}
|
||
}
|