ReadViewSDK/ReadViewDemo/ReadViewDemo/ViewController.swift

415 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}