- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置 - 文本选择改为自定义手势+操作栏(拷贝/高亮/批注) - 添加 accessibilityIdentifier 支持自动化 UI 测试 - 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等 - 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读) - 新增文档:UI 自动化测试、功能开发计划、阅读器规划
289 lines
12 KiB
Swift
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"
|
||
}
|
||
}
|
||
}
|