// // RDReaderController.swift // RDReaderDemo // // Created by yangsq on 2021/8/10. // import SSAlertSwift import UIKit /// 书籍来源类型 public enum BookSource { /// TXT文件(Bundle内置) case textFile /// EPUB文件 case epubFile(url: URL) } public class RDReaderController: UIViewController { private static let epubPaginationCacheStorageKey = "epubPaginationCache.key" private static let epubValidationStateStorageKey = "ssreader.epub.validation.state" public lazy var topToolView: RDReaderTopToolView = { let view = RDReaderTopToolView( frame: CGRect( x: 0, y: 0, width: view.frame.width, height: 44 + UIApplication.shared.statusBarFrame.height ) ) return view }() public lazy var bottomToolView: RDReaderBottomToolView = { let view = RDReaderBottomToolView( frame: CGRect( x: 0, y: 0, width: view.frame.width, height: 55 + RDReaderCommon.safeAreaInsets.bottom ) ) return view }() public lazy var readerView: RDReaderView = { let readerView = RDReaderView() return readerView }() // MARK: - TXT模式数据 var chapterPages = [ChapterPage]() var book = Book(desc: nil, chapters: []) var currentChapterNum: Int = 0 // MARK: - EPUB模式数据 var epubParser: RDEPUBParser? var epubPublication: RDEPUBPublication? var epubSession: RDEPUBReadingSession? var epubPaginator: RDEPUBPaginator? var epubReadingProfile: RDEPUBReadingProfile? var epubTextBook: LegacyRDEPUBTextBook? var epubTOCItems: [EPUBTOCDisplayItem] = [] var epubHighlights: [RDEPUBHighlight] = [] var currentEPUBBookIdentifier: String? private var epubPreloadViews: [Int: RDReaderEPUBContentView] = [:] private var epubPageCurlViews: [Int: RDReaderEPUBContentView] = [:] private var isBackgroundEPUBPaginationRunning = false private var backgroundEPUBPaginationApplyWorkItem: DispatchWorkItem? private var epubValidationRunStartedAt: Date? private var epubValidationRunReason = "initialLoad" private var epubPages: [EPUBPage] { epubSession?.activePages ?? [] } private var isTextEPUBMode: Bool { epubReadingProfile == .textReflowable } private var epubChapters: [EPUBChapterInfo] { epubSession?.activeChapters ?? [] } var epubResolver: RDEPUBResourceResolver? { epubSession?.resourceResolver ?? epubPublication?.resourceResolver } func storeEPUBValidationState(_ values: [String: Any]) { var state = UserDefaults.standard.dictionary( forKey: Self.epubValidationStateStorageKey ) ?? [:] values.forEach { state[$0.key] = $0.value } state["timestamp"] = ISO8601DateFormatter().string(from: Date()) UserDefaults.standard .set(state, forKey: Self.epubValidationStateStorageKey) } func beginEPUBValidationRun( reason: String, restoreLocation: RDEPUBLocation? ) { epubValidationRunStartedAt = Date() epubValidationRunReason = reason var state = epubValidationSettingsSnapshot() state["validationReason"] = reason state["restoreRequestedHref"] = restoreLocation?.href ?? "" state["restoreRequestedFragment"] = restoreLocation?.fragment ?? "" storeEPUBValidationState(state) } func storeEPUBPaginationValidation( mode: String, stage: String, pageCount: Int, chapterCount: Int, tocCount: Int, restoreLocation: RDEPUBLocation?, resolvedRestorePage: Int? ) { var state = epubValidationSettingsSnapshot() state["mode"] = mode state["validationReason"] = epubValidationRunReason state["paginationStage"] = stage state["pageCount"] = pageCount state["chapterCount"] = chapterCount state["tocCount"] = tocCount state["restoreRequestedHref"] = restoreLocation?.href ?? "" state["restoreRequestedFragment"] = restoreLocation?.fragment ?? "" state["restoreResolvedPage"] = resolvedRestorePage ?? 0 if let startedAt = epubValidationRunStartedAt { state["paginationDurationMs"] = Int( Date().timeIntervalSince(startedAt) * 1000 ) } storeEPUBValidationState(state) } func storeEPUBTOCValidation(item: EPUBTOCDisplayItem, resolvedPage: Int?) { storeEPUBValidationState([ "tocSelectionTitle": item.title, "tocSelectionHref": item.href, "tocSelectionDepth": item.depth, "tocSelectionResolvedPage": resolvedPage ?? 0, "tocSelectionCount": epubTOCItems.count, ]) } func storeEPUBEndValidationState(pageNum: Int, reachedEndScreen: Bool) { storeEPUBValidationState([ "lastVisitedPage": pageNum, "didReachReadableEnd": true, "didReachEndScreen": reachedEndScreen, ]) } private func epubValidationSettingsSnapshot() -> [String: Any] { [ "settingsTheme": RDReaderManager.shared.themeType.currentType .rawValue, "settingsFontSize": RDReaderManager.shared.fontValue.currentType, "settingsLineSpacing": RDReaderManager.shared.lineSpaceType .currentType.rawValue, "settingsLineSpacingMultiple": RDReaderManager.shared.lineSpaceType .currentType.mulitiple, "settingsDisplayType": RDReaderManager.shared.pageCurlTypeType .currentType.rawValue, "settingsBrightness": RDReaderManager.shared.brightValue .currentType, ] } private func currentReadableEPUBPageCount() -> Int { isTextEPUBMode ? (epubTextBook?.pages.count ?? 0) : epubPages.count } private func resolvedEPUBRestorePageNumber(for location: RDEPUBLocation?) -> Int? { guard let location else { return nil } return epubSession? .pageIndex(for: location, bookIdentifier: currentEPUBBookIdentifier) .map { $0 + 1 } } /// 加载指示器 private lazy var loadingIndicator: UIActivityIndicatorView = { let indicator: UIActivityIndicatorView if #available(iOS 13.0, *) { indicator = UIActivityIndicatorView(style: .large) } else { indicator = UIActivityIndicatorView(style: .whiteLarge) } indicator.hidesWhenStopped = true indicator.color = .gray return indicator }() private lazy var epubPaginationHostView: UIView = { let hostView = UIView(frame: .zero) hostView.alpha = 0.01 hostView.isUserInteractionEnabled = false hostView.clipsToBounds = true hostView.accessibilityElementsHidden = true return hostView }() private lazy var epubPreloadHostView: UIView = { let hostView = UIView(frame: .zero) hostView.alpha = 0.01 hostView.isUserInteractionEnabled = false hostView.clipsToBounds = true hostView.accessibilityElementsHidden = true return hostView }() var isReaderContentReady = false /// 书籍来源,默认为TXT文件 var bookSource: BookSource = .textFile /// 便捷初始化,指定EPUB文件 convenience init(epubURL: URL) { self.init() self.bookSource = .epubFile(url: epubURL) } public override func viewDidLoad() { super.viewDidLoad() makeUI() switch bookSource { case .textFile: loadData() isReaderContentReady = true readerView.reloadData() transitionToPage() case .epubFile(let url): loadEPUBData(url: url) } } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController? .setNavigationBarHidden(true, animated: animated) } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.navigationController? .setNavigationBarHidden(false, animated: animated) } // MARK: - TXT数据加载 func loadData() { updateReaderViewConfiguration() epubPublication = nil epubSession = nil epubReadingProfile = nil epubTextBook = nil currentEPUBBookIdentifier = nil epubTOCItems = [] epubHighlights = [] clearStagedBackgroundEPUBPagination() clearEPUBPreloadCache() let fontSize = RDReaderManager.shared.fontValue.currentType let lineSapce = RDReaderManager.shared.lineSpaceType.currentType let pageWidth: CGFloat if readerView.pagesPerScreen > 1 { pageWidth = view.frame.width / CGFloat( readerView.pagesPerScreen ) - 16 * 2 } else { pageWidth = view.frame.width - 16 * 2 } let pageSize = CGSize( width: pageWidth, height: view.frame.height - 40 * 2 ) let font = UIFont.systemFont(ofSize: fontSize) let lineSpaceValue = lineSapce.mulitiple * 15 book = RDReaderManager.shared .encodeTextFile( font: font, lineSapce: lineSpaceValue, size: pageSize ) chapterPages = book.chapters.reduce( [], { result, chapter in result + chapter.pages } ) } func transitionToPage(rangeLocation: Int? = nil) { var rangeLoc = RDReaderManager.shared.readProgress.currentType if rangeLocation != nil { rangeLoc = rangeLocation! } if let pageNum = chapterPages.firstIndex( where: { chapterPage in let chapter = self.book.chapters[chapterPage.chapterSort - 1] let chapterRange = chapter.range let range = NSMakeRange( chapterRange.location + chapterPage.range.location, chapterPage.range.length ) return range.contains(rangeLoc) }) { readerView.transitionToPage(pageNum: pageNum + 1) } } // MARK: - EPUB数据加载(异步) func loadEPUBData(url: URL) { isReaderContentReady = false currentEPUBBookIdentifier = nil epubTOCItems = [] epubHighlights = [] clearStagedBackgroundEPUBPagination() clearEPUBPreloadCache() // 显示加载指示器 showLoading() // 1. 解析EPUB结构 let parser = RDEPUBParser() do { try parser.parse(epubURL: url) } catch { hideLoading() print("EPUB解析失败: \(error.localizedDescription)") return } self.epubParser = parser let publication = parser.makePublication() self.epubPublication = publication let session = RDEPUBReadingSession(publication: publication) session.transition(to: .loading) self.epubSession = session self.epubReadingProfile = publication.readingProfile self.epubTextBook = nil updateReaderViewConfiguration() self.currentEPUBBookIdentifier = parser.metadata.identifier ?? url.lastPathComponent self.epubHighlights = storedEPUBHighlights() let savedLocation = storedEPUBLocation() beginEPUBValidationRun( reason: "initialLoad", restoreLocation: savedLocation ) let debugBookName = title ?? url.deletingPathExtension().lastPathComponent print( "[EPUB] Load book=\(debugBookName) profile=\(publication.readingProfile.rawValue) spine=\(parser.spine.count)" ) storeEPUBValidationState([ "bookTitle": debugBookName, "profile": publication.readingProfile.rawValue, "spineCount": parser.spine.count, "bookIdentifier": currentEPUBBookIdentifier ?? "", ]) if publication.readingProfile == .textReflowable { loadEPUBTextBook( parser: parser, publication: publication, restoreLocation: savedLocation ) return } // 2. 使用WKWebView预计算每章页数 let layoutContext = currentEPUBLayoutContext() let viewportSize = layoutContext.viewportSize let preferences = RDReaderManager.shared.epubPreferences( reflowableInsets: layoutContext.reflowableContentInsets, fixedContentInset: layoutContext.fixedContentInset ) if let cacheKey = epubPaginationCacheKey( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple ), let cachedPageCounts = cachedEPUBPageCounts( for: cacheKey, expectedSpineCount: parser.spine.count ) { finishEPUBPagination( pageCounts: cachedPageCounts, restoreLocation: savedLocation, validationStage: "cache" ) return } if parser.metadata.layout != .fixed { let initialSpineIndex = initialEPUBSpineIndex( for: savedLocation, parser: parser ) let paginator = RDEPUBPaginator() self.epubPaginator = paginator paginator.calculateSingleSpinePageCount( parser: parser, spineIndex: initialSpineIndex, hostingView: ensureEPUBPaginationHostView( viewportSize: viewportSize ), presentation: preferences .presentationStyle(viewportSize: viewportSize) ) { [weak self] pageCount in guard let self = self else { return } var provisionalPageCounts = Array( repeating: 1, count: parser.spine.count ) provisionalPageCounts[initialSpineIndex] = max(1, pageCount) self.finishEPUBPagination( pageCounts: provisionalPageCounts, restoreLocation: savedLocation, validationStage: "provisional" ) self.epubPaginator = nil self.startBackgroundEPUBPagination( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple, restoreLocation: savedLocation ) } return } let paginator = RDEPUBPaginator() self.epubPaginator = paginator paginator.calculate( parser: parser, hostingView: ensureEPUBPaginationHostView( viewportSize: viewportSize ), presentation: preferences .presentationStyle(viewportSize: viewportSize) ) { [weak self] pageCounts in guard let self = self else { return } if let cacheKey = self.epubPaginationCacheKey( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple ) { self.saveEPUBPageCounts(pageCounts, for: cacheKey) } self.finishEPUBPagination( pageCounts: pageCounts, restoreLocation: savedLocation, validationStage: "initial" ) self.epubPaginator = nil } } /// 重新分页EPUB(字号/行距改变时) func repaginateEPUB(reason: String = "repaginate") { guard let parser = epubParser, let session = epubSession else { return } updateReaderViewConfiguration() session.transition(to: .repaginating) isReaderContentReady = false showLoading() clearStagedBackgroundEPUBPagination() clearEPUBPreloadCache() let layoutContext = currentEPUBLayoutContext() let viewportSize = layoutContext.viewportSize let preferences = RDReaderManager.shared.epubPreferences( reflowableInsets: layoutContext.reflowableContentInsets, fixedContentInset: layoutContext.fixedContentInset ) let savedLocation = storedEPUBLocation() ?? currentVisibleEPUBLocation() beginEPUBValidationRun(reason: reason, restoreLocation: savedLocation) if isTextEPUBMode, let publication = epubPublication { loadEPUBTextBook( parser: parser, publication: publication, restoreLocation: savedLocation ) return } if let cacheKey = epubPaginationCacheKey( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple ), let cachedPageCounts = cachedEPUBPageCounts( for: cacheKey, expectedSpineCount: parser.spine.count ) { finishEPUBPagination( pageCounts: cachedPageCounts, restoreLocation: savedLocation, validationStage: "cache" ) return } if parser.metadata.layout != .fixed { let initialSpineIndex = initialEPUBSpineIndex( for: savedLocation, parser: parser ) let paginator = RDEPUBPaginator() self.epubPaginator = paginator paginator.calculateSingleSpinePageCount( parser: parser, spineIndex: initialSpineIndex, hostingView: ensureEPUBPaginationHostView( viewportSize: viewportSize ), presentation: preferences .presentationStyle(viewportSize: viewportSize) ) { [weak self] pageCount in guard let self = self else { return } var provisionalPageCounts = Array( repeating: 1, count: parser.spine.count ) provisionalPageCounts[initialSpineIndex] = max(1, pageCount) self.finishEPUBPagination( pageCounts: provisionalPageCounts, restoreLocation: savedLocation, validationStage: "provisional" ) self.epubPaginator = nil self.startBackgroundEPUBPagination( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple, restoreLocation: savedLocation ) } return } let paginator = RDEPUBPaginator() self.epubPaginator = paginator paginator.calculate( parser: parser, hostingView: ensureEPUBPaginationHostView( viewportSize: viewportSize ), presentation: preferences .presentationStyle(viewportSize: viewportSize) ) { [weak self] pageCounts in guard let self = self else { return } if let cacheKey = self.epubPaginationCacheKey( parser: parser, viewportSize: viewportSize, padding: preferences.reflowableContentInsets, fontSize: preferences.fontSize, lineHeightMultiple: preferences.lineHeightMultiple ) { self.saveEPUBPageCounts(pageCounts, for: cacheKey) } self.finishEPUBPagination( pageCounts: pageCounts, restoreLocation: savedLocation, validationStage: "initial" ) self.epubPaginator = nil } } private func finishEPUBPagination( pageCounts: [Int], restoreLocation: RDEPUBLocation?, validationStage: String ) { guard let session = epubSession else { return } let snapshot = session.makePaginationSnapshot( pageCounts: pageCounts, preferences: currentEPUBPreferences(), layoutContext: currentEPUBLayoutContext() ) applyEPUBPaginationSnapshot( snapshot, restoreLocation: restoreLocation, hideLoadingIndicator: true, validationStage: validationStage ) } private func applyEPUBPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation?, hideLoadingIndicator: Bool, validationStage: String ) { epubSession?.setActiveSnapshot(snapshot) if let publication = epubPublication { epubTOCItems = flattenedTOCItems(from: publication.tableOfContents) } storeEPUBPaginationValidation( mode: epubReadingProfile?.rawValue ?? "unknown", stage: validationStage, pageCount: snapshot.pages.count, chapterCount: snapshot.chapters.count, tocCount: epubTOCItems.count, restoreLocation: restoreLocation, resolvedRestorePage: resolvedEPUBRestorePageNumber( for: restoreLocation ) ) isReaderContentReady = true if hideLoadingIndicator { hideLoading() } readerView.reloadData() restoreEPUBLocation(restoreLocation) if restoreLocation == nil { epubSession?.transition(to: .idle) } } private func queueBackgroundEPUBPaginationApplication( pageCounts: [Int], restoreLocation: RDEPUBLocation? ) { guard let session = epubSession else { return } let snapshot = session.makePaginationSnapshot( pageCounts: pageCounts, preferences: currentEPUBPreferences(), layoutContext: currentEPUBLayoutContext() ) epubSession?.stageSnapshot(snapshot, restoreLocation: restoreLocation) schedulePendingBackgroundEPUBPaginationApplication() } private func schedulePendingBackgroundEPUBPaginationApplication() { backgroundEPUBPaginationApplyWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.applyPendingBackgroundEPUBPaginationIfNeeded() } backgroundEPUBPaginationApplyWorkItem = workItem DispatchQueue.main .asyncAfter(deadline: .now() + 0.35, execute: workItem) } private func applyPendingBackgroundEPUBPaginationIfNeeded() { if let staged = epubSession?.consumeStagedSnapshotIfAllowed() { let restoreLocation = staged.restoreLocation ?? storedEPUBLocation() ?? currentVisibleEPUBLocation() backgroundEPUBPaginationApplyWorkItem = nil applyEPUBPaginationSnapshot( staged.snapshot, restoreLocation: restoreLocation, hideLoadingIndicator: false, validationStage: "backgroundFinal" ) return } guard epubSession?.stagedSnapshot() != nil else { return } schedulePendingBackgroundEPUBPaginationApplication() } private func clearStagedBackgroundEPUBPagination() { backgroundEPUBPaginationApplyWorkItem?.cancel() backgroundEPUBPaginationApplyWorkItem = nil epubSession?.clearStagedSnapshot() } private func initialEPUBSpineIndex( for location: RDEPUBLocation?, parser: RDEPUBParser ) -> Int { let _ = parser return epubSession?.initialSpineIndex(for: location) ?? 0 } private func startBackgroundEPUBPagination( parser: RDEPUBParser, viewportSize: CGSize, padding: UIEdgeInsets, fontSize: CGFloat, lineHeightMultiple: CGFloat, restoreLocation: RDEPUBLocation? ) { guard !isBackgroundEPUBPaginationRunning else { return } isBackgroundEPUBPaginationRunning = true let paginator = RDEPUBPaginator() self.epubPaginator = paginator let preferences = currentEPUBPreferences() paginator.calculate( parser: parser, hostingView: ensureEPUBPaginationHostView( viewportSize: viewportSize ), presentation: preferences .presentationStyle(viewportSize: viewportSize) ) { [weak self] pageCounts in guard let self = self else { return } self.isBackgroundEPUBPaginationRunning = false if let cacheKey = self.epubPaginationCacheKey( parser: parser, viewportSize: viewportSize, padding: padding, fontSize: fontSize, lineHeightMultiple: lineHeightMultiple ) { self.saveEPUBPageCounts(pageCounts, for: cacheKey) } let currentLocation = self.storedEPUBLocation() ?? self.currentVisibleEPUBLocation() ?? restoreLocation self.queueBackgroundEPUBPaginationApplication( pageCounts: pageCounts, restoreLocation: currentLocation ) self.epubPaginator = nil } } private func epubPaginationCacheKey( parser: RDEPUBParser, viewportSize: CGSize, padding: UIEdgeInsets, fontSize: CGFloat, lineHeightMultiple: CGFloat ) -> String? { let bookSignature: String switch bookSource { case .textFile: return nil case .epubFile(let url): let attributes = try? FileManager.default.attributesOfItem( atPath: url.path ) let fileSize = (attributes?[.size] as? NSNumber)?.stringValue ?? "0" let modifiedAt = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 bookSignature = [ url.lastPathComponent, fileSize, String(format: "%.0f", modifiedAt), ] .joined(separator: "|") } let layoutSignature = [ Int(viewportSize.width.rounded()).description, Int(viewportSize.height.rounded()).description, Int(padding.top.rounded()).description, Int(padding.left.rounded()).description, Int(padding.bottom.rounded()).description, Int(padding.right.rounded()).description, String(format: "%.2f", fontSize), String(format: "%.2f", lineHeightMultiple), String(readerView.pagesPerScreen), parser.metadata.layout.rawValue, String(parser.spine.count), ].joined(separator: "|") return bookSignature + "|" + layoutSignature } private func cachedEPUBPageCounts(for key: String, expectedSpineCount: Int) -> [Int]? { let cache = UserDefaults.standard.dictionary( forKey: Self.epubPaginationCacheStorageKey ) ?? [:] let rawCounts = cache[key] let counts: [Int]? if let values = rawCounts as? [Int] { counts = values } else if let values = rawCounts as? [NSNumber] { counts = values.map(\.intValue) } else { counts = nil } guard let counts, counts.count == expectedSpineCount else { return nil } return counts.map { max(1, $0) } } private func saveEPUBPageCounts(_ pageCounts: [Int], for key: String) { var cache = UserDefaults.standard.dictionary( forKey: Self.epubPaginationCacheStorageKey ) ?? [:] cache[key] = pageCounts.map { max(1, $0) } UserDefaults.standard .set(cache, forKey: Self.epubPaginationCacheStorageKey) } private func currentEPUBLayoutContext() -> RDEPUBNavigatorLayoutContext { let readerBounds = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size let safeAreaInsets = view.window?.safeAreaInsets ?? view.safeAreaInsets return RDEPUBNavigatorLayoutContext( containerSize: readerBounds, pagesPerScreen: readerView.pagesPerScreen, safeAreaInsets: safeAreaInsets, userInterfaceIdiom: UIDevice.current.userInterfaceIdiom ) } private func currentEPUBPreferences(theme: RDReaderTheme? = nil) -> RDEPUBPreferences { let layoutContext = currentEPUBLayoutContext() return RDReaderManager.shared.epubPreferences( theme: theme, reflowableInsets: layoutContext.reflowableContentInsets, fixedContentInset: layoutContext.fixedContentInset ) } private func renderRequest( for pageNum: Int, page: EPUBPage, publication: RDEPUBPublication, theme: RDReaderTheme ) -> RDEPUBRenderRequest? { let preferences = currentEPUBPreferences(theme: theme) let layoutContext = currentEPUBLayoutContext() let targetLocation = epubSession?.pendingLocation( forPageNumber: pageNum, spineIndex: page.fixedSpread == nil ? page.spineIndex : nil ) return preferences.renderRequest( for: page, publication: publication, viewportSize: layoutContext.viewportSize, targetLocation: targetLocation, highlights: highlights(for: page.spineIndex) ) } private func updateReaderViewConfiguration() { switch bookSource { case .textFile: readerView.landscapeDualPageEnabled = true readerView.pageDirection = .rightToLeft case .epubFile: let isFixedLayout = epubPublication?.layout == .fixed readerView.landscapeDualPageEnabled = !isFixedLayout switch epubPublication?.readingProgression { case .ltr: readerView.pageDirection = .leftToRight case .rtl: readerView.pageDirection = .rightToLeft case .some(.auto): readerView.pageDirection = .rightToLeft case .none: readerView.pageDirection = .rightToLeft } let preferredDisplayType = effectiveDisplayType( requested: RDReaderManager.shared.pageCurlTypeType.currentType .value ) if readerView.currentDisplayType != preferredDisplayType { readerView.switchReaderDisplayType(preferredDisplayType) } } } private func effectiveDisplayType(requested: RDReaderView.DisplayType) -> RDReaderView.DisplayType { guard epubPublication?.layout == .fixed else { return requested } if requested == .pageCurl { return .horizontalScroll } return requested } private func saveEPUBLocation(_ location: RDEPUBLocation) { let scopedLocation = RDEPUBLocation( bookIdentifier: currentEPUBBookIdentifier, href: location.href, progression: location.progression, lastProgression: location.lastProgression, fragment: location.fragment ) guard let data = try? JSONEncoder().encode(scopedLocation), let json = String(data: data, encoding: .utf8) else { return } RDReaderManager.shared.epubReadLocation.currentType = json } private func saveEPUBSelection(_ selection: RDEPUBSelection?) { guard let selection = selection, !selection.isEmpty else { RDReaderManager.shared.epubSelection.currentType = "" return } let scopedSelection = RDEPUBSelection( bookIdentifier: currentEPUBBookIdentifier, location: RDEPUBLocation( bookIdentifier: currentEPUBBookIdentifier, href: selection.location.href, progression: selection.location.progression, lastProgression: selection.location.lastProgression, fragment: selection.location.fragment ), text: selection.text, rangeInfo: selection.rangeInfo, createdAt: selection.createdAt ) guard let data = try? JSONEncoder().encode(scopedSelection), let json = String(data: data, encoding: .utf8) else { return } RDReaderManager.shared.epubSelection.currentType = json } private func storedEPUBSelection() -> RDEPUBSelection? { let json = RDReaderManager.shared.epubSelection.currentType guard !json.isEmpty, let data = json.data(using: .utf8) else { return nil } guard let selection = try? JSONDecoder().decode( RDEPUBSelection.self, from: data ) else { return nil } guard selection.bookIdentifier == currentEPUBBookIdentifier else { return nil } return selection } private func allStoredEPUBHighlights() -> [RDEPUBHighlight] { let json = RDReaderManager.shared.epubHighlights.currentType guard !json.isEmpty, let data = json.data(using: .utf8) else { return [] } return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? [] } private func storedEPUBHighlights() -> [RDEPUBHighlight] { allStoredEPUBHighlights() .filter { $0.bookIdentifier == currentEPUBBookIdentifier } } private func saveEPUBHighlights(_ highlights: [RDEPUBHighlight]) { let scopedHighlights = highlights.map { highlight in RDEPUBHighlight( id: highlight.id, bookIdentifier: currentEPUBBookIdentifier, location: RDEPUBLocation( bookIdentifier: currentEPUBBookIdentifier, href: highlight.location.href, progression: highlight.location.progression, lastProgression: highlight.location.lastProgression, fragment: highlight.location.fragment ), text: highlight.text, rangeInfo: highlight.rangeInfo, color: highlight.color, note: highlight.note, createdAt: highlight.createdAt ) } let otherBookHighlights = allStoredEPUBHighlights().filter { $0.bookIdentifier != currentEPUBBookIdentifier } let mergedHighlights = otherBookHighlights + scopedHighlights guard let data = try? JSONEncoder().encode(mergedHighlights), let json = String(data: data, encoding: .utf8) else { return } RDReaderManager.shared.epubHighlights.currentType = json } private func addHighlightFromCurrentSelection() { guard let selection = storedEPUBSelection(), let normalizedLocation = normalizedEPUBLocation( selection.location ), !selection.isEmpty else { return } let newHighlight = RDEPUBHighlight( bookIdentifier: currentEPUBBookIdentifier, location: normalizedLocation, text: selection.text, rangeInfo: selection.rangeInfo ) var highlights = storedEPUBHighlights() let isDuplicate = highlights.contains { highlight in highlight.location.href == newHighlight.location.href && highlight.location.fragment == newHighlight.location.fragment && highlight.text == newHighlight.text && highlight.rangeInfo == newHighlight.rangeInfo } guard !isDuplicate else { return } highlights.append(newHighlight) epubHighlights = highlights saveEPUBHighlights(highlights) saveEPUBSelection(nil) let currentLocation = currentVisibleEPUBLocation() if isReaderContentReady { readerView.reloadData() restoreEPUBLocation(currentLocation) } } private func storedEPUBLocation() -> RDEPUBLocation? { let json = RDReaderManager.shared.epubReadLocation.currentType guard !json.isEmpty, let data = json.data(using: .utf8) else { return nil } guard let location = try? JSONDecoder().decode( RDEPUBLocation.self, from: data ) else { return nil } guard location.bookIdentifier == currentEPUBBookIdentifier else { return nil } return location } private func currentVisibleEPUBLocation() -> RDEPUBLocation? { if isTextEPUBMode { return epubTextBook? .location( forPageNumber: readerView.currentPage, bookIdentifier: currentEPUBBookIdentifier ) } if let location = epubSession?.currentReadingContext?.location { return location } return epubSession?.currentVisibleLocation( currentPageNumber: readerView.currentPage, bookIdentifier: currentEPUBBookIdentifier ) } private func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { if let fixedSpread = page.fixedSpread { return fixedSpread.resources .contains(where: { $0.spineIndex == spineIndex }) } return page.spineIndex == spineIndex } private func fallbackEPUBLocation(for page: EPUBPage) -> RDEPUBLocation? { epubSession? .fallbackLocation( for: page, bookIdentifier: currentEPUBBookIdentifier ) } private func normalizedEPUBHref( _ href: String, relativeTo spineIndex: Int? = nil ) -> String? { epubResolver?.normalizedHref(href, relativeToSpineIndex: spineIndex) ?? href.components(separatedBy: "#").first } private func normalizedEPUBLocation( _ location: RDEPUBLocation, relativeTo spineIndex: Int? = nil ) -> RDEPUBLocation? { epubResolver?.normalizedLocation( location, relativeToSpineIndex: spineIndex, bookIdentifier: currentEPUBBookIdentifier ) } private func currentEPUBTOCIndex() -> Int { guard !epubTOCItems.isEmpty else { return currentChapterNum } if isTextEPUBMode { let currentPageNumber = readerView.currentPage guard currentPageNumber > 0 else { return 0 } return epubTOCItems .lastIndex( where: { ($0.pageNumber ?? Int.max) <= currentPageNumber }) ?? 0 } let referenceLocation = storedEPUBLocation() ?? currentVisibleEPUBLocation() guard let location = referenceLocation, let normalizedLocation = normalizedEPUBLocation(location) else { return 0 } if let exactIndex = epubTOCItems.firstIndex(where: { item in guard let normalizedItem = normalizedEPUBLocation( RDEPUBLocation( bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0 ) ) else { return false } return normalizedItem.href == normalizedLocation.href && normalizedItem.fragment == normalizedLocation.fragment }) { return exactIndex } if let pathIndex = epubTOCItems.lastIndex(where: { item in guard let normalizedItem = normalizedEPUBLocation( RDEPUBLocation( bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0 ) ) else { return false } return normalizedItem.href == normalizedLocation.href }) { return pathIndex } return 0 } private func pageIndex(for location: RDEPUBLocation) -> Int? { epubSession? .pageIndex(for: location, bookIdentifier: currentEPUBBookIdentifier) } private func highlights(for spineIndex: Int) -> [RDEPUBHighlight] { guard let parser = epubParser, parser.spine.indices .contains(spineIndex) else { return [] } let targetHref = normalizedEPUBHref(parser.spine[spineIndex].href) return epubHighlights.filter { highlight in normalizedEPUBHref(highlight.location.href) == targetHref } } private func restoreEPUBLocationIfNeeded() { restoreEPUBLocation(storedEPUBLocation()) } func restoreEPUBLocation(_ location: RDEPUBLocation?) { if isTextEPUBMode { guard let location, let targetPageNum = epubTextBook?.pageNumber( for: location, resolver: epubResolver ?? publicationFallbackResolver(), bookIdentifier: currentEPUBBookIdentifier ) else { epubSession?.transition(to: .idle) return } readerView.transitionToPage(pageNum: targetPageNum) return } guard let session = epubSession, let location = location, let targetPageNum = session.queueNavigation( to: location, relativeToSpineIndex: nil, bookIdentifier: currentEPUBBookIdentifier ) else { epubSession?.transition(to: .idle) return } readerView.transitionToPage(pageNum: targetPageNum) } private func navigateToEPUBLink( _ location: RDEPUBLocation, fromSpineIndex spineIndex: Int ) { guard let session = epubSession, let targetPageNum = session.queueNavigation( to: location, relativeToSpineIndex: spineIndex, bookIdentifier: currentEPUBBookIdentifier ) else { return } readerView.transitionToPage(pageNum: targetPageNum) } // MARK: - Loading Indicator private func showLoading() { view.addSubview(loadingIndicator) loadingIndicator.center = view.center loadingIndicator.startAnimating() } func hideLoading() { loadingIndicator.stopAnimating() loadingIndicator.removeFromSuperview() } private func ensureEPUBPaginationHostView(viewportSize: CGSize) -> UIView { let hostFrame = CGRect( x: -viewportSize.width - 32, y: 0, width: max(viewportSize.width, 1), height: max(viewportSize.height, 1) ) if epubPaginationHostView.superview == nil { view.addSubview(epubPaginationHostView) } epubPaginationHostView.frame = hostFrame view.sendSubviewToBack(epubPaginationHostView) return epubPaginationHostView } private func ensureEPUBPreloadHostView(viewportSize: CGSize) -> UIView { let hostFrame = CGRect( x: -viewportSize.width - 32, y: 0, width: max(viewportSize.width, 1), height: max(viewportSize.height, 1) ) if epubPreloadHostView.superview == nil { view.addSubview(epubPreloadHostView) } epubPreloadHostView.frame = hostFrame view.sendSubviewToBack(epubPreloadHostView) return epubPreloadHostView } private func clearEPUBPreloadCache() { epubPreloadViews.values.forEach { $0.releaseResources() $0.removeFromSuperview() } epubPreloadViews.removeAll() epubPageCurlViews.values.forEach { $0.releaseResources() $0.removeFromSuperview() } epubPageCurlViews.removeAll() } private func releaseEPUBPageViews(except keepPages: Set) { epubPreloadViews.forEach { key, value in guard !keepPages.contains(key) else { return } value.releaseResources() value.removeFromSuperview() } epubPreloadViews = epubPreloadViews.filter { key, _ in keepPages.contains(key) } epubPageCurlViews.forEach { key, value in guard !keepPages.contains(key) else { return } value.releaseResources() value.removeFromSuperview() } epubPageCurlViews = epubPageCurlViews.filter { key, _ in keepPages.contains(key) } } private func trimEPUBPageCurlCache(around pageNum: Int) { let keepPages = Set( [ pageNum - 1, pageNum, pageNum + 1, ].filter { index in index > 0 && index < pageCountOfReaderView( readerView: readerView ) - 1 } ) releaseEPUBPageViews(except: keepPages) } private func primeEPUBPageCache(around pageNum: Int) { guard case .epubFile = bookSource, let publication = epubPublication, publication.layout != .fixed, pageNum > 0, pageNum < pageCountOfReaderView(readerView: readerView) - 1 else { return } let validPages = Set( [pageNum - 1, pageNum + 1].filter { index in index > 0 && index < pageCountOfReaderView( readerView: readerView ) - 1 } ) epubPreloadViews = epubPreloadViews.filter { key, value in let shouldKeep = validPages.contains(key) if !shouldKeep { value.releaseResources() value.removeFromSuperview() } return shouldKeep } let theme = RDReaderManager.shared.themeType.currentType.value let viewportSize = currentEPUBLayoutContext().viewportSize let preloadHostView = ensureEPUBPreloadHostView( viewportSize: viewportSize ) for targetPage in validPages.sorted() { let contentView = configuredEPUBContentView( pageNum: targetPage, contentView: cachedEPUBPreloadView(pageNum: targetPage), theme: theme, publication: publication, isInteractive: false ) if contentView.superview == nil { preloadHostView.addSubview(contentView) contentView.frame = preloadHostView.bounds } } } private func cachedEPUBPreloadView(pageNum: Int) -> RDReaderEPUBContentView { if let cachedView = epubPreloadViews[pageNum] { return cachedView } let contentView = RDReaderEPUBContentView() epubPreloadViews[pageNum] = contentView return contentView } private func cachedEPUBPageCurlView(pageNum: Int) -> RDReaderEPUBContentView { if let cachedView = epubPageCurlViews[pageNum] { return cachedView } if let preloadedView = epubPreloadViews.removeValue(forKey: pageNum) { preloadedView.removeFromSuperview() epubPageCurlViews[pageNum] = preloadedView return preloadedView } let contentView = RDReaderEPUBContentView() epubPageCurlViews[pageNum] = contentView return contentView } private func configuredEPUBContentView( pageNum: Int, contentView: RDReaderEPUBContentView, theme: RDReaderTheme, publication: RDEPUBPublication, isInteractive: Bool = true ) -> RDReaderEPUBContentView { let epubPage = epubPages[pageNum - 1] contentView.delegate = isInteractive ? self : nil contentView.backgroundColor = theme.contentBackgroudColor contentView.pageNumLabel.textColor = theme.contentTextColor contentView.pageNumLabel.text = "\(pageNum) / \(epubPages.count)" let isFixedLayout = publication.layout == .fixed guard let request = renderRequest( for: pageNum, page: epubPage, publication: publication, theme: theme ) else { return contentView } contentView.load(publication: publication, request: request) return contentView } // MARK: - UI Setup func makeUI() { readerView.delegate = self readerView.dataSource = self readerView.landscapeDualPageEnabled = true readerView.coverPageIndex = 0 readerView.pageDirection = .rightToLeft readerView.frame = CGRect( x: 0, y: 0, width: view.frame.width, height: view.frame.height ) readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(readerView) readerView .register( contentView: RDReaderContentView.self, contentViewWithReuseIdentifier: NSStringFromClass( RDReaderContentView.self ) ) readerView .register( contentView: RDReaderEPUBContentView.self, contentViewWithReuseIdentifier: NSStringFromClass( RDReaderEPUBContentView.self ) ) readerView .register( contentView: RDReaderEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass( RDReaderEPUBTextContentView.self ) ) readerView .register( contentView: RDReaderCoverView.self, contentViewWithReuseIdentifier: NSStringFromClass( RDReaderCoverView.self ) ) readerView .register( contentView: RDReaderEndView.self, contentViewWithReuseIdentifier: NSStringFromClass( RDReaderEndView.self ) ) topToolView.trigger { [weak self] event in guard let self = self else { return } switch event { case .back: self.navigationController?.popViewController(animated: true) } } bottomToolView.trigger { [weak self] event in guard let self = self else { return } switch event { case .chapterList: self.showChapterListView() case .highlight: if case .epubFile = self.bookSource { self.addHighlightFromCurrentSelection() } case .settings: self.showEditView() case .darkAndLight: if RDReaderManager.shared.themeType.currentType != .dark { RDReaderManager.shared.themeType.currentType = .dark } else { RDReaderManager.shared.themeType.currentType = .white } } } RDReaderManager.shared.themeType.observeChange { [weak self] theme in guard let self = self else { return } self.readerView.backgroundColor = theme.contentBackgroudColor self.topToolView.backgroundColor = theme.toolBackgroudColor self.bottomToolView.backgroundColor = theme.toolBackgroudColor self.topToolView.lineView.backgroundColor = theme.toolLineColor self.bottomToolView.lineView.backgroundColor = theme.toolLineColor self.topToolView.backButton.tintColor = theme.toolControlTextColor self.bottomToolView.chapterListButton.tintColor = theme.toolControlTextColor self.bottomToolView.highlightButton.tintColor = theme.toolControlTextColor self.bottomToolView.settingsButton.tintColor = theme.toolControlTextColor self.bottomToolView.lightModeButton.tintColor = theme.toolControlTextColor if self.isReaderContentReady { self.readerView.reloadData() } } RDReaderManager.shared.pageCurlTypeType .observeChange { [weak self] type in guard let self = self else { return } self.readerView .switchReaderDisplayType( self.effectiveDisplayType(requested: type) ) if case .epubFile = self.bookSource { self.storeEPUBValidationState([ "validationReason": "displayTypeChanged", "settingsDisplayType": RDReaderManager.shared .pageCurlTypeType.currentType.rawValue, "lastVisiblePage": self.readerView.currentPage, ]) } } RDReaderManager.shared.fontValue.observeChange { [weak self] _ in guard let self = self else { return } guard self.isReaderContentReady else { return } switch self.bookSource { case .textFile: self.loadData() self.readerView.reloadData() case .epubFile: self.repaginateEPUB(reason: "fontSizeChanged") } } RDReaderManager.shared.lineSpaceType.observeChange { [weak self] _ in guard let self = self else { return } guard self.isReaderContentReady else { return } switch self.bookSource { case .textFile: self.loadData() self.readerView.reloadData() case .epubFile: self.repaginateEPUB(reason: "lineSpacingChanged") } } } func showChapterListView() { switch bookSource { case .textFile: showTXTChapterList() case .epubFile: showEPUBChapterList() } } private func showTXTChapterList() { let chapterListVC = SSChapterListController( chapters: self.book.chapters, currentChapterNum: currentChapterNum ) chapterListVC.view.ss_w = view.frame.width * 0.8 chapterListVC.view.ss_h = view.frame.height let alertView = SSAlertView( customView: chapterListVC.view, fromViewController: self, canPanDimiss: false ) let animation = SSAlertDefaultAnmation(state: .fromLeft) animation.isSpringShowAnimation = false alertView.animation = animation alertView.show() chapterListVC.selectedChapter { [weak alertView, weak self] number in guard let self = self else { return } let chapter = self.book.chapters[number] self.transitionToPage(rangeLocation: chapter.range.location) alertView?.hide() } } private func showEPUBChapterList() { let currentIndex = currentEPUBTOCIndex() let chapters: [Chapter] if !epubTOCItems.isEmpty { chapters = epubTOCItems .enumerated() .map { (index, item) -> Chapter in let indent = String(repeating: " ", count: item.depth) return Chapter( sort: index + 1, title: indent + item.title, pageCount: 0, content: "", pages: [], range: NSMakeRange(0, 0) ) } } else if isTextEPUBMode, let textBook = epubTextBook { chapters = textBook.chapters.enumerated().map { index, chapter in Chapter( sort: index + 1, title: chapter.title, pageCount: chapter.pages.count, content: "", pages: [], range: NSMakeRange(0, 0) ) } } else { chapters = epubChapters .enumerated() .map { (index, info) -> Chapter in Chapter( sort: index + 1, title: info.title, pageCount: info.pageCount, content: "", pages: [], range: NSMakeRange(0, 0) ) } } let chapterListVC = SSChapterListController( chapters: chapters, currentChapterNum: currentIndex ) chapterListVC.view.ss_w = view.frame.width * 0.8 chapterListVC.view.ss_h = view.frame.height let alertView = SSAlertView( customView: chapterListVC.view, fromViewController: self, canPanDimiss: false ) let animation = SSAlertDefaultAnmation(state: .fromLeft) animation.isSpringShowAnimation = false alertView.animation = animation alertView.show() chapterListVC.selectedChapter { [weak alertView, weak self] number in guard let self = self else { return } if !self.epubTOCItems.isEmpty { let item = self.epubTOCItems[number] if self.isTextEPUBMode, let pageNumber = item.pageNumber { self.storeEPUBTOCValidation( item: item, resolvedPage: pageNumber ) self.readerView.transitionToPage(pageNum: pageNumber) } else { let location = RDEPUBLocation( bookIdentifier: self.currentEPUBBookIdentifier, href: item.href, progression: 0, fragment: nil ) let fromSpineIndex = self.readerView.currentPage > 0 && self.epubPages.indices.contains( self.readerView.currentPage - 1 ) ? self.epubPages[self.readerView.currentPage - 1] .spineIndex : 0 let resolvedPage = self.epubSession?.pageIndex( for: location, bookIdentifier: self.currentEPUBBookIdentifier ).map { $0 + 1 } self.storeEPUBTOCValidation( item: item, resolvedPage: resolvedPage ) self.navigateToEPUBLink( location, fromSpineIndex: fromSpineIndex ) } } else if self.isTextEPUBMode, let textPage = self.epubTextBook?.pages.first( where: { $0.chapterIndex == number }) { self.readerView .transitionToPage(pageNum: textPage.absolutePageIndex + 1) } else if let pageIndex = self.epubPages.firstIndex( where: { $0.chapterIndex == number }) { self.readerView.transitionToPage(pageNum: pageIndex + 1) } alertView?.hide() } } func showEditView() { let customView = RDReaderEditView( frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 300) ) customView.brightSlider .setValue( Float(RDReaderManager.shared.brightValue.currentType), animated: false ) customView.fontView.currentFontSize = RDReaderManager.shared.fontValue.currentType customView.lineSpaceView.currentType = RDReaderManager.shared.lineSpaceType.currentType customView.pageCurlTypeView.currentType = RDReaderManager.shared.pageCurlTypeType.currentType customView.colorView.currentType = RDReaderManager.shared.themeType.currentType let alertView = SSAlertView( customView: customView, fromViewController: self, animation: SSAlertDefaultAnmation(state: .fromBottom), canPanDimiss: false ) alertView.show() customView.brightSlider.trigger { event in switch event { case .valueDidChange(let value): RDReaderManager.shared.brightValue.currentType = value } } customView.fontView.trigger { event in switch event { case .fontSize(let size): RDReaderManager.shared.fontValue.currentType = size } } customView.lineSpaceView.trigger { event in switch event { case .chose(let type): RDReaderManager.shared.lineSpaceType.currentType = type } } customView.pageCurlTypeView.trigger { event in switch event { case .chose(let type): RDReaderManager.shared.pageCurlTypeType.currentType = type } } customView.colorView.trigger { event in switch event { case .chose(let type): RDReaderManager.shared.themeType.currentType = type } } } } // MARK: - RDReaderDataSource & RDReaderDelegate extension RDReaderController: RDReaderDataSource, RDReaderDelegate { public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { if pageNum == 0 { return NSStringFromClass(RDReaderCoverView.self) } else if pageNum == pageCountOfReaderView(readerView: readerView) - 1 { return NSStringFromClass(RDReaderEndView.self) } switch bookSource { case .textFile: return NSStringFromClass(RDReaderContentView.self) case .epubFile: return isTextEPUBMode ? NSStringFromClass(RDReaderEPUBTextContentView.self) : NSStringFromClass(RDReaderEPUBContentView.self) } } public func pageCountOfReaderView(readerView: RDReaderView) -> Int { switch bookSource { case .textFile: return chapterPages.count + 2 // +cover +end case .epubFile: return (isTextEPUBMode ? (epubTextBook?.pages.count ?? 0) : epubPages.count) + 2 } } public func pageContentView( readerView: RDReaderView, pageNum: Int, containerView: UIView? ) -> UIView { let totalPages = pageCountOfReaderView(readerView: readerView) let theme = RDReaderManager.shared.themeType.currentType.value // 封面页 if pageNum == 0 { let contentView = (containerView as? RDReaderCoverView) ?? RDReaderCoverView() contentView.backgroundColor = theme.contentBackgroudColor contentView.textLabel.textColor = theme.contentTextColor switch bookSource { case .textFile: contentView.textLabel.text = book.desc case .epubFile: contentView.textLabel.text = epubParser?.metadata.title ?? "EPUB" if let coverImage = epubParser?.coverImage() { contentView.imageV.image = coverImage } } return contentView } // 结束页 if pageNum == totalPages - 1 { let contentView = (containerView as? RDReaderEndView) ?? RDReaderEndView() contentView.backgroundColor = theme.contentBackgroudColor return contentView } // 内容页 switch bookSource { case .textFile: return txtContentView( pageNum: pageNum, containerView: containerView, theme: theme ) case .epubFile: return isTextEPUBMode ? epubTextContentView( pageNum: pageNum, containerView: containerView, theme: theme ) : epubContentView( pageNum: pageNum, containerView: containerView, theme: theme ) } } /// TXT内容页 private func txtContentView( pageNum: Int, containerView: UIView?, theme: RDReaderTheme ) -> UIView { let chapter = chapterPages[pageNum - 1] let contentView = (containerView as? RDReaderContentView) ?? RDReaderContentView() contentView.textLabel.textColor = theme.contentTextColor contentView.pageNumLabel.textColor = theme.contentTextColor contentView.backgroundColor = theme.contentBackgroudColor contentView.textLabel.attributedText = chapter.attrContent contentView.pageNumLabel.text = "\(pageNum) / \(chapterPages.count)" return contentView } /// EPUB内容页(WKWebView渲染) private func epubContentView( pageNum: Int, containerView: UIView?, theme: RDReaderTheme ) -> UIView { guard let publication = epubPublication else { return containerView ?? UIView() } let contentView: RDReaderEPUBContentView if readerView.currentDisplayType == .pageCurl, containerView == nil { contentView = cachedEPUBPageCurlView(pageNum: pageNum) } else { contentView = (containerView as? RDReaderEPUBContentView) ?? RDReaderEPUBContentView() } return configuredEPUBContentView( pageNum: pageNum, contentView: contentView, theme: theme, publication: publication, isInteractive: true ) } private func epubTextContentView( pageNum: Int, containerView: UIView?, theme: RDReaderTheme ) -> UIView { guard let page = epubTextBook?.page(at: pageNum) else { return containerView ?? UIView() } let contentView = (containerView as? RDReaderEPUBTextContentView) ?? RDReaderEPUBTextContentView() contentView .configure( page: page, pageNumber: pageNum, totalPages: epubTextBook?.pages.count ?? 0, theme: theme ) return contentView } public func pageNum(readerView: RDReaderView, pageNum: Int) { switch bookSource { case .textFile: if pageNum > 0 && chapterPages.count > pageNum - 1 { let chapterPage = chapterPages[pageNum - 1] let chapter = book.chapters[chapterPage.chapterSort - 1] let chapterRange = chapter.range let rangeLoc = chapterRange.location + chapterPage.range.location RDReaderManager.shared.readProgress.currentType = rangeLoc currentChapterNum = chapterPage.chapterSort - 1 } case .epubFile: let readablePageCount = currentReadableEPUBPageCount() if readablePageCount > 0 { if pageNum == readablePageCount { storeEPUBEndValidationState( pageNum: pageNum, reachedEndScreen: false ) } else if pageNum == readablePageCount + 1 { storeEPUBEndValidationState( pageNum: pageNum, reachedEndScreen: true ) } } if isTextEPUBMode { guard pageNum > 0, let page = epubTextBook?.page(at: pageNum), let location = epubTextBook?.location( forPageNumber: pageNum, bookIdentifier: currentEPUBBookIdentifier ) else { return } currentChapterNum = page.chapterIndex saveEPUBLocation(location) saveEPUBSelection(nil) epubSession?.transition(to: .idle) return } if pageNum > 0 && epubPages.count > pageNum - 1 { let epubPage = epubPages[pageNum - 1] if epubSession?.navigatorState == .idle { epubSession?.transition(to: .moving) } currentChapterNum = epubPage.chapterIndex if let location = fallbackEPUBLocation(for: epubPage) { saveEPUBLocation(location) } saveEPUBSelection(nil) schedulePendingBackgroundEPUBPaginationApplication() primeEPUBPageCache(around: pageNum) trimEPUBPageCurlCache(around: pageNum) if epubSession?.pendingNavigationPageNum == nil { epubSession?.transition(to: .idle) } } } } public func topToolView(readerView: RDReaderView) -> UIView? { return topToolView } public func bottomToolView(readerView: RDReaderView) -> UIView? { return bottomToolView } /// 横竖屏切换时重新分页 public func readerViewOrientationWillChange( readerView: RDReaderView, isLandscape: Bool ) { switch bookSource { case .textFile: loadData() transitionToPage() case .epubFile: repaginateEPUB(reason: "orientationChanged") } } } extension RDReaderController: RDReaderEPUBContentViewDelegate { func epubContentView( _ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int ) { guard readerView.currentPage > 0, epubPages.indices .contains(readerView.currentPage - 1) else { return } let currentPage = epubPages[readerView.currentPage - 1] guard epubSession? .pageContains(spineIndex: spineIndex, in: currentPage) == true else { return } saveEPUBLocation(location) epubSession?.updateReadingContext( pageNumber: readerView.currentPage, location: location, spineIndex: spineIndex, chapterIndex: currentPage.chapterIndex, bookIdentifier: currentEPUBBookIdentifier ) } func epubContentView( _ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int ) { guard readerView.currentPage > 0, epubPages.indices .contains(readerView.currentPage - 1) else { return } let currentPage = epubPages[readerView.currentPage - 1] guard epubSession? .pageContains(spineIndex: spineIndex, in: currentPage) == true else { return } saveEPUBSelection(selection) } func epubContentView( _ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int ) { saveEPUBSelection(nil) navigateToEPUBLink(location, fromSpineIndex: fromSpineIndex) } func epubContentView( _ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL ) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } func epubContentView( _ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String ) { print("EPUB JS Error: \(message)") } }