- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator - Update EPUB core: parser, reading session, JS bridge, navigator layout - Update reader controller: data source, location resolution, persistence - Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator - Remove deprecated navigation state machine and pagination state - Update text rendering: book cache, HTML normalizer - Update UI: text content view, dark image adjuster, text selection controller - Update settings and reader configuration - Add CODE_REVIEW.md and AUDIT_FINAL.md documentation - Update pod dependencies (remove SSAlertSwift, SnapKit) - Update podspec and pod configuration files Co-Authored-By: Claude <noreply@anthropic.com>
505 lines
20 KiB
Swift
505 lines
20 KiB
Swift
|
|
import UIKit
|
|
|
|
public final class RDURLReaderController: 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: RDReaderView.DisplayType) {
|
|
readerController?.configuration.displayType = 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: [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)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func performDemoSearch(keyword: String) {
|
|
guard let readerController else { return }
|
|
readerController.showSearchBar()
|
|
if readerController.parser != nil || readerController.textBook != nil {
|
|
submitSearchAfterDelay(keyword: keyword, retries: 5)
|
|
} 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()
|
|
if retries > 0,
|
|
readerController.searchState?.matches.isEmpty != false,
|
|
readerController.parser == nil {
|
|
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: 5)
|
|
}
|
|
|
|
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 safeInsets = 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 = RDPlainTextBookBuilder(
|
|
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
|
|
)
|
|
)
|
|
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()
|
|
}
|
|
|
|
private var readerController: RDEPUBReaderController? {
|
|
embeddedController as? RDEPUBReaderController
|
|
}
|
|
|
|
@discardableResult
|
|
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
|
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)",
|
|
"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()))"
|
|
].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,
|
|
let rangeLocation = match.rangeLocation,
|
|
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
|
|
return "none"
|
|
}
|
|
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
|
|
guard nsRange.location >= 0,
|
|
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
|
|
return "none"
|
|
}
|
|
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
|
|
}
|
|
|
|
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 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, 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 RDReaderView.DisplayType {
|
|
|
|
var demoArgumentValue: String {
|
|
switch self {
|
|
case .pageCurl:
|
|
return "pageCurl"
|
|
case .horizontalScroll:
|
|
return "horizontalScroll"
|
|
case .verticalScroll:
|
|
return "verticalScroll"
|
|
}
|
|
}
|
|
}
|