Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift
T
shenandshen 1efb9d172f feat(reader): 增强阅读器功能与 UI 测试支持
- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
2026-05-31 23:56:54 +08:00

289 lines
12 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 {
/// 书籍文件 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
}()
/// 初始化方法
/// - 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 = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
if moved {
emitDemoState(prefix: "page=\(pageNumber)")
}
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
}
/// 输出当前阅读器状态日志(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, didUpdateLocation location: RDEPUBLocation) {
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"
}
}
}