415 lines
17 KiB
Swift
415 lines
17 KiB
Swift
import UIKit
|
||
import RDEpubReaderView
|
||
import RDPDFReaderView
|
||
|
||
final class ViewController: UIViewController {
|
||
private struct DemoBook: Equatable {
|
||
let title: String
|
||
let detail: String
|
||
let fileURL: URL
|
||
}
|
||
|
||
private struct LaunchAutomationPlan {
|
||
let bookTitleQuery: String
|
||
let displayType: RDEpubReaderView.DisplayType?
|
||
let pageNumber: Int?
|
||
let resetsReaderState: Bool
|
||
let displaySequence: [RDEpubReaderView.DisplayType]
|
||
let stepDelay: TimeInterval
|
||
let windowSize: Int?
|
||
let concurrency: Int?
|
||
let clearsCache: Bool
|
||
let corruptsCache: Bool
|
||
let allowsInspectableWebViews: Bool
|
||
let enablesVerboseWebLogs: Bool
|
||
let searchKeyword: String?
|
||
let pdfTextSource: String?
|
||
|
||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.DisplayType? {
|
||
switch rawValue.lowercased() {
|
||
case "pagecurl", "curl":
|
||
return .pageCurl
|
||
case "horizontalscroll", "horizontal", "scroll":
|
||
return .horizontalScroll
|
||
case "verticalscroll", "vertical":
|
||
return .verticalScroll
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
static func parse(arguments: [String]) -> LaunchAutomationPlan? {
|
||
func value(after flag: String) -> String? {
|
||
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
|
||
return nil
|
||
}
|
||
return arguments[index + 1]
|
||
}
|
||
|
||
guard let bookTitleQuery = value(after: "--demo-book-title"),
|
||
!bookTitleQuery.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
|
||
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
|
||
let resetsReaderState = arguments.contains("--demo-reset-state")
|
||
let displaySequence = value(after: "--demo-display-sequence")
|
||
.map { raw in
|
||
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
||
} ?? []
|
||
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0
|
||
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
||
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||
let clearsCache = arguments.contains("--demo-clear-cache")
|
||
let corruptsCache = arguments.contains("--demo-corrupt-cache")
|
||
let allowsInspectableWebViews = arguments.contains("--demo-allow-inspectable-webviews")
|
||
let enablesVerboseWebLogs = arguments.contains("--demo-enable-verbose-weblogs")
|
||
let searchKeyword = value(after: "--demo-search-keyword")
|
||
let pdfTextSource = value(after: "--demo-pdf-text-source")
|
||
|
||
return LaunchAutomationPlan(
|
||
bookTitleQuery: bookTitleQuery,
|
||
displayType: displayType,
|
||
pageNumber: pageNumber,
|
||
resetsReaderState: resetsReaderState,
|
||
displaySequence: displaySequence,
|
||
stepDelay: stepDelay,
|
||
windowSize: windowSize,
|
||
concurrency: concurrency,
|
||
clearsCache: clearsCache,
|
||
corruptsCache: corruptsCache,
|
||
allowsInspectableWebViews: allowsInspectableWebViews,
|
||
enablesVerboseWebLogs: enablesVerboseWebLogs,
|
||
searchKeyword: searchKeyword,
|
||
pdfTextSource: pdfTextSource
|
||
)
|
||
}
|
||
}
|
||
|
||
private let statusLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.numberOfLines = 0
|
||
label.textAlignment = .left
|
||
label.textColor = .secondaryLabel
|
||
label.font = .systemFont(ofSize: 15)
|
||
label.text = "ReadViewSDK Demo"
|
||
label.accessibilityIdentifier = "demo.status"
|
||
return label
|
||
}()
|
||
|
||
private let tableView: UITableView = {
|
||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||
tableView.accessibilityIdentifier = "demo.books.table"
|
||
tableView.rowHeight = 72
|
||
tableView.tableFooterView = UIView()
|
||
return tableView
|
||
}()
|
||
|
||
private lazy var emptyStateLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.numberOfLines = 0
|
||
label.textAlignment = .center
|
||
label.textColor = .secondaryLabel
|
||
label.font = .systemFont(ofSize: 15)
|
||
label.text = "book 目录下暂时没有可阅读的 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
|
||
label.accessibilityIdentifier = "demo.books.empty"
|
||
return label
|
||
}()
|
||
|
||
private var books: [DemoBook] = []
|
||
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
|
||
private var didRunLaunchAutomation = false
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .systemBackground
|
||
view.accessibilityIdentifier = "demo.root"
|
||
title = "书库"
|
||
setupViews()
|
||
loadBooks()
|
||
}
|
||
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
runLaunchAutomationIfNeeded()
|
||
}
|
||
|
||
private func setupViews() {
|
||
tableView.dataSource = self
|
||
tableView.delegate = self
|
||
|
||
view.addSubview(statusLabel)
|
||
view.addSubview(tableView)
|
||
|
||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
NSLayoutConstraint.activate([
|
||
statusLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||
|
||
tableView.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 12),
|
||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||
])
|
||
}
|
||
|
||
private func loadBooks() {
|
||
books = discoverBooks()
|
||
tableView.reloadData()
|
||
updateChrome()
|
||
}
|
||
|
||
private func updateChrome() {
|
||
if books.isEmpty {
|
||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
|
||
tableView.backgroundView = emptyStateLabel
|
||
} else {
|
||
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||
tableView.backgroundView = nil
|
||
}
|
||
}
|
||
|
||
private func discoverBooks() -> [DemoBook] {
|
||
let fileManager = FileManager.default
|
||
let bookDirectoryURLs = [
|
||
Bundle.main.resourceURL?.appendingPathComponent("book", isDirectory: true),
|
||
Bundle.main.resourceURL
|
||
].compactMap { $0 }
|
||
|
||
let fileURLs = bookDirectoryURLs.flatMap { directoryURL in
|
||
(try? fileManager.contentsOfDirectory(
|
||
at: directoryURL,
|
||
includingPropertiesForKeys: [.isRegularFileKey],
|
||
options: [.skipsHiddenFiles]
|
||
)) ?? []
|
||
}
|
||
|
||
return fileURLs
|
||
.filter { url in
|
||
let ext = url.pathExtension.lowercased()
|
||
return RDReaderDocumentFormat.supports(url) || ext == "pdf"
|
||
}
|
||
.reduce(into: [URL]()) { result, url in
|
||
if !result.contains(url) {
|
||
result.append(url)
|
||
}
|
||
}
|
||
.sorted { lhs, rhs in
|
||
lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending
|
||
}
|
||
.map { fileURL in
|
||
let ext = fileURL.pathExtension.uppercased()
|
||
let title = fileURL.deletingPathExtension().lastPathComponent
|
||
return DemoBook(
|
||
title: title,
|
||
detail: ext.isEmpty ? fileURL.lastPathComponent : "\(ext) · \(fileURL.lastPathComponent)",
|
||
fileURL: fileURL
|
||
)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
private func openBook(
|
||
_ book: DemoBook,
|
||
configuration: RDEPUBReaderConfiguration = .default,
|
||
automationPlan: LaunchAutomationPlan? = nil
|
||
) -> RDEpubURLReaderController {
|
||
let controller = RDEpubURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||
controller.title = book.title
|
||
|
||
if let navigationController {
|
||
navigationController.pushViewController(controller, animated: true)
|
||
} else {
|
||
let hostController = UINavigationController(rootViewController: controller)
|
||
hostController.modalPresentationStyle = .fullScreen
|
||
hostController.view.accessibilityIdentifier = "demo.reader.host"
|
||
present(hostController, animated: true)
|
||
}
|
||
|
||
if let automationPlan {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in
|
||
if let displayType = automationPlan.displayType {
|
||
controller?.applyDemoDisplayType(displayType)
|
||
}
|
||
if let pageNumber = automationPlan.pageNumber {
|
||
_ = controller?.goToDemoPage(pageNumber)
|
||
}
|
||
if !automationPlan.displaySequence.isEmpty {
|
||
controller?.runDemoDisplaySequence(
|
||
automationPlan.displaySequence,
|
||
initialPageNumber: nil,
|
||
stepDelay: automationPlan.stepDelay
|
||
)
|
||
}
|
||
if let searchKeyword = automationPlan.searchKeyword {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
controller?.performDemoSearch(keyword: searchKeyword)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return controller
|
||
}
|
||
|
||
@discardableResult
|
||
private func openPDFBook(
|
||
_ book: DemoBook,
|
||
automationPlan: LaunchAutomationPlan? = nil
|
||
) -> PDFDemoReaderViewController {
|
||
let textSource = automationPlan?.pdfTextSource
|
||
.flatMap(PDFDemoImageTextSource.init(rawValue:)) ?? .vision
|
||
// Demo 的文件名在 bundle 更新后保持稳定;正式宿主应传入自己的书籍 ID(并按账号、
|
||
// 内容版本隔离),避免把沙盒绝对路径作为标注存储键。
|
||
let controller = PDFDemoReaderViewController(
|
||
url: book.fileURL,
|
||
bookIdentifier: "demo.pdf.\(book.fileURL.lastPathComponent)",
|
||
textSource: textSource
|
||
)
|
||
if let navigationController {
|
||
navigationController.pushViewController(controller, animated: true)
|
||
} else {
|
||
let hostController = UINavigationController(rootViewController: controller)
|
||
hostController.modalPresentationStyle = .fullScreen
|
||
hostController.view.accessibilityIdentifier = "demo.pdf-reader.host"
|
||
present(hostController, animated: true)
|
||
}
|
||
if let automationPlan {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak controller] in
|
||
if let displayType = automationPlan.displayType {
|
||
let pdfDisplayType: RDPDFReaderView.DisplayType
|
||
switch displayType {
|
||
case .pageCurl: pdfDisplayType = .pageCurl
|
||
case .horizontalScroll: pdfDisplayType = .horizontalScroll
|
||
case .verticalScroll: pdfDisplayType = .verticalScroll
|
||
}
|
||
controller?.applyDemoDisplayType(pdfDisplayType)
|
||
}
|
||
if let pageNumber = automationPlan.pageNumber {
|
||
controller?.goToDemoPage(pageNumber)
|
||
}
|
||
}
|
||
}
|
||
return controller
|
||
}
|
||
|
||
private func runLaunchAutomationIfNeeded() {
|
||
guard !didRunLaunchAutomation,
|
||
let launchAutomationPlan,
|
||
!books.isEmpty else {
|
||
return
|
||
}
|
||
didRunLaunchAutomation = true
|
||
|
||
guard let book = books.first(where: { $0.title.localizedCaseInsensitiveContains(launchAutomationPlan.bookTitleQuery) }) else {
|
||
print("[ReadViewDemo] automation skipped: missing book matching \(launchAutomationPlan.bookTitleQuery)")
|
||
return
|
||
}
|
||
|
||
if launchAutomationPlan.resetsReaderState {
|
||
resetPersistedReaderState()
|
||
}
|
||
if launchAutomationPlan.clearsCache {
|
||
clearChapterSummaryCache()
|
||
}
|
||
if launchAutomationPlan.corruptsCache {
|
||
corruptChapterSummaryCache()
|
||
}
|
||
|
||
var configuration = RDEPUBReaderConfiguration.default
|
||
if let displayType = launchAutomationPlan.displayType {
|
||
configuration.displayType = displayType
|
||
}
|
||
if let windowSize = launchAutomationPlan.windowSize {
|
||
configuration.onDemandChapterWindowSize = windowSize
|
||
}
|
||
if let concurrency = launchAutomationPlan.concurrency {
|
||
configuration.metadataParsingConcurrency = concurrency
|
||
}
|
||
configuration.allowsInspectableWebViews = launchAutomationPlan.allowsInspectableWebViews
|
||
configuration.enablesVerboseWebViewLogging = launchAutomationPlan.enablesVerboseWebLogs
|
||
if book.fileURL.pathExtension.lowercased() == "pdf" {
|
||
_ = openPDFBook(book, automationPlan: launchAutomationPlan)
|
||
} else {
|
||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||
}
|
||
}
|
||
|
||
private func resetPersistedReaderState() {
|
||
let defaults = UserDefaults.standard
|
||
let prefixes = [
|
||
"ssreader.epub.location.",
|
||
"ssreader.epub.bookmarks.",
|
||
"ssreader.epub.highlights."
|
||
]
|
||
let settingsKey = "ssreader.epub.settings"
|
||
|
||
for key in defaults.dictionaryRepresentation().keys {
|
||
if prefixes.contains(where: { key.hasPrefix($0) }) || key == settingsKey {
|
||
defaults.removeObject(forKey: key)
|
||
}
|
||
}
|
||
PDFDemoReaderViewController.resetPersistedAnnotations()
|
||
}
|
||
|
||
private func clearChapterSummaryCache() {
|
||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.temporaryDirectory
|
||
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||
try? FileManager.default.removeItem(at: cacheDir)
|
||
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
|
||
}
|
||
|
||
private func corruptChapterSummaryCache() {
|
||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.temporaryDirectory
|
||
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||
guard let enumerator = FileManager.default.enumerator(at: cacheDir, includingPropertiesForKeys: nil) else {
|
||
return
|
||
}
|
||
for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" {
|
||
try? Data("corrupted-cache".utf8).write(to: fileURL, options: .atomic)
|
||
}
|
||
print("[ReadViewDemo] corrupted chapter summary cache at \(cacheDir.path)")
|
||
}
|
||
|
||
}
|
||
|
||
extension ViewController: UITableViewDataSource {
|
||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
books.count
|
||
}
|
||
|
||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let cellIdentifier = "demo.book.cell"
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ??
|
||
UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||
let book = books[indexPath.row]
|
||
|
||
cell.accessoryType = .disclosureIndicator
|
||
cell.textLabel?.text = book.title
|
||
cell.textLabel?.numberOfLines = 2
|
||
cell.detailTextLabel?.text = book.detail
|
||
cell.detailTextLabel?.textColor = .secondaryLabel
|
||
cell.accessibilityIdentifier = "demo.book.\(indexPath.row)"
|
||
return cell
|
||
}
|
||
}
|
||
|
||
extension ViewController: UITableViewDelegate {
|
||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
guard books.indices.contains(indexPath.row) else { return }
|
||
let book = books[indexPath.row]
|
||
if book.fileURL.pathExtension.lowercased() == "pdf" {
|
||
_ = openPDFBook(book)
|
||
} else {
|
||
openBook(book)
|
||
}
|
||
}
|
||
}
|