ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift

564 lines
24 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
public final class RDEpubURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
let animated: Bool
var attemptCount: Int
}
private let bookURL: URL
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
private var demoStateTimer: Timer?
private var lastEmittedDemoState = ""
private var pendingSearchKeyword: String?
private var externalLinkActivationCount = 0
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
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
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
embedReaderController()
installDemoStateLabel()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startDemoStateTimerIfNeeded()
refreshDemoState()
}
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopDemoStateTimer()
}
deinit {
stopDemoStateTimer()
}
public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
if let readerController {
readerController.configuration.displayType = displayType
} else {
cbzReaderController?.setDisplayType(displayType)
}
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
}
@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
}
public func runDemoDisplaySequence(
_ displayTypes: [RDEpubReaderView.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)
}
}
}
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
if readerController.parser != nil || readerController.textBook != nil {
submitSearchAfterDelay(keyword: keyword, retries: 10)
} else {
pendingSearchKeyword = keyword
}
}
private func submitSearchAfterDelay(keyword: String, retries: Int = 0) {
guard let readerController else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
readerController.search(keyword: keyword)
readerController.updateSearchCount()
// bookPageMap
// token
if retries > 0,
readerController.searchState?.matches.isEmpty != false {
self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
}
}
}
private func executePendingSearchIfNeeded() {
guard let keyword = pendingSearchKeyword else { return }
guard let readerController, readerController.parser != nil || readerController.textBook != nil else {
return
}
pendingSearchKeyword = nil
submitSearchAfterDelay(keyword: keyword, retries: 10)
}
private func embedReaderController() {
let controller: UIViewController
switch RDReaderDocumentFormat(fileURL: bookURL) {
case .epub:
controller = RDEPUBReaderController(
epubURL: bookURL,
configuration: epubConfiguration
)
case .cbz:
controller = RDCBZReaderController(bookURL: bookURL, configuration: epubConfiguration)
case .mobi, .plainText:
let bookIdentifier = bookURL.lastPathComponent
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
let edgeInsets = UIEdgeInsets(
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom),
right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right)
)
let builder = RDEpubPlainTextBookBuilder(
layoutConfig: RDEPUBTextLayoutConfig(
frameWidth: pageSize.width,
frameHeight: pageSize.height,
edgeInsets: edgeInsets,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
)
do {
let source: (textURL: URL, title: String)
if RDReaderDocumentFormat(fileURL: bookURL) == .mobi {
let document = try RDMOBIParser().parse(fileURL: bookURL)
source = (try cachedMOBITextURL(text: document.text), document.title)
} else {
source = (bookURL, bookURL.deletingPathExtension().lastPathComponent)
}
let textBook = try builder.build(textFileURL: source.textURL, pageSize: pageSize, style: renderStyle)
controller = RDEPUBReaderController(
textBook: textBook,
bookIdentifier: bookIdentifier,
title: source.title,
textFileURL: source.textURL,
configuration: epubConfiguration
)
} catch {
controller = makeErrorController(error)
}
case nil:
controller = makeErrorController(
NSError(
domain: "RDEpubReaderView.UnsupportedFormat",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "不支持此文件格式"]
)
)
}
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()
}
private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
private var cbzReaderController: RDCBZReaderController? {
embeddedController as? RDCBZReaderController
}
@discardableResult
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
if let cbzReaderController {
return cbzReaderController.goToPage(pageNumber, animated: animated)
}
guard let readerController else { return false }
guard pageNumber > 0 else { return false }
let knownPages = readerController.readerContext.bookPageMap?.totalPages
?? readerController.textBook?.pages.count
?? readerController.activePages.count
let numPages = readerController.readerView.numberOfPages()
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
if moved {
emitDemoState(prefix: "page=\(pageNumber)")
}
return moved
}
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
return
}
self.pendingDemoPageRequest = pendingDemoPageRequest
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
self?.attemptPendingDemoPageNavigation()
}
}
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"
}
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 startDemoStateTimerIfNeeded() {
guard demoStateTimer == nil else { return }
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshDemoState(logIfChanged: false)
}
if let demoStateTimer {
RunLoop.main.add(demoStateTimer, forMode: .common)
}
}
private func stopDemoStateTimer() {
demoStateTimer?.invalidate()
demoStateTimer = nil
}
private func emitDemoState(prefix: String? = nil) {
refreshDemoState(logPrefix: prefix, logIfChanged: true)
}
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
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 bookmarks = readerController?.activeBookmarks.count ?? 0
let selection = readerController?.currentSelection == nil ? 0 : 1
let location = readerController?.currentLocation
let href = encodedDemoLocationHref(location?.href)
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let cfi = encodedDemoField(location?.cfi)
let lastCFI = encodedDemoField(location?.lastCFI)
let rangeCFI = encodedDemoField(location?.rangeCFI)
let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
?? (fileCount: 0, totalBytes: 0)
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
let state = [
"reader=opened",
"page=\(page)",
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
"display=\(display)",
"toolbar=\(toolbar)",
"highlights=\(highlights)",
"bookmarks=\(bookmarks)",
"selection=\(selection)",
"href=\(href)",
"progression=\(progression)",
"cfi=\(cfi)",
"lastCFI=\(lastCFI)",
"rangeCFI=\(rangeCFI)",
"mode=\(mapSnapshot.mode)",
"pagination=\(mapSnapshot.phase)",
"knownPages=\(mapSnapshot.knownPages)",
"knownChapters=\(mapSnapshot.knownChapters)",
"buildableChapters=\(mapSnapshot.buildableChapters)",
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
"inspectable=\(inspectable ? 1 : 0)",
"streamedResources=\(resourceMetrics.streamedResponses)",
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
"resourceFailures=\(resourceMetrics.failures)",
"cacheFiles=\(cacheStats.fileCount)",
"cacheBytes=\(cacheStats.totalBytes)",
"externalLinks=\(externalLinkActivationCount)",
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))",
"footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))"
].joined(separator: " ")
demoStateLabel.text = state
if let logPrefix {
logDemoState(prefix: logPrefix)
} else if logIfChanged, state != lastEmittedDemoState {
}
lastEmittedDemoState = state
}
private func encodedDemoLocationHref(_ href: String?) -> String {
guard let href, !href.isEmpty else { return "nil" }
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
}
private func encodedDemoField(_ value: String?) -> String {
guard let value, !value.isEmpty else { return "nil" }
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
}
private func currentSearchMatchText() -> String {
guard let match = readerController?.searchState?.currentMatch else {
return "none"
}
// Primary path: extract the exact matched text from the chapter content
if let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) {
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
if nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length {
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
}
}
// Fallback: chapter data may not be cached yet (on-demand loading).
// If the match length equals the keyword length, the keyword itself
// is the match text. Otherwise extract from previewText at the known offset.
if let keyword = readerController?.searchState?.keyword,
match.rangeLength == keyword.count {
return keyword
}
// Final fallback: return "none" to indicate data not yet available
return "none"
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
guard let readerController else {
return ("unavailable", "none", 0, 0, 0)
}
if let bookPageMap = readerController.readerContext.bookPageMap {
let buildableChapters = readerController.publication?.spine.filter {
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
}.count ?? 0
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
}
if let textBook = readerController.textBook {
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
}
let snapshotChapters = readerController.activeChapters.count
let snapshotPages = readerController.activePages.count
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
}
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
)
}
private func cachedMOBITextURL(text: String) throws -> URL {
let attributes = try? FileManager.default.attributesOfItem(atPath: bookURL.path)
let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
let safeName = bookURL.deletingPathExtension().lastPathComponent
.replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression)
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let directory = base.appendingPathComponent("ssreaderview-mobi", isDirectory: true)
.appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true)
let textURL = directory.appendingPathComponent("content.txt")
if FileManager.default.fileExists(atPath: textURL.path) { return textURL }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data(text.utf8).write(to: textURL, options: .atomic)
return textURL
}
private func makeErrorController(_ error: Error) -> UIViewController {
let fallback = UIViewController()
fallback.view.backgroundColor = .systemBackground
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .secondaryLabel
label.text = error.localizedDescription
label.translatesAutoresizingMaskIntoConstraints = false
fallback.view.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: fallback.view.leadingAnchor, constant: 24),
label.trailingAnchor.constraint(equalTo: fallback.view.trailingAnchor, constant: -24),
label.centerYAnchor.constraint(equalTo: fallback.view.centerYAnchor)
])
return fallback
}
}
extension RDEpubURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
retryPendingDemoPageIfNeeded()
executePendingSearchIfNeeded()
emitDemoState()
}
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
retryPendingDemoPageIfNeeded()
executePendingSearchIfNeeded()
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)")
}
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
}
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
externalLinkActivationCount += 1
lastActivatedExternalURL = url
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
}
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
lastReaderErrorDescription = String(describing: error)
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
}
}
private extension RDEpubReaderView.DisplayType {
var demoArgumentValue: String {
switch self {
case .pageCurl:
return "pageCurl"
case .horizontalScroll:
return "horizontalScroll"
case .verticalScroll:
return "verticalScroll"
}
}
}