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: 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 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 = 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 = 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()))", "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 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" } } }