import UIKit public final class RDEPUBReaderController: UIViewController { private struct RDEPUBViewportSignature: Equatable { let width: CGFloat let height: CGFloat let safeTop: CGFloat let safeLeft: CGFloat let safeBottom: CGFloat let safeRight: CGFloat func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool { abs(width - other.width) > threshold || abs(height - other.height) > threshold || abs(safeTop - other.safeTop) > threshold || abs(safeLeft - other.safeLeft) > threshold || abs(safeBottom - other.safeBottom) > threshold || abs(safeRight - other.safeRight) > threshold } } private enum RDEPUBViewportChangeReason { case viewLayout case orientationTransition case pendingRepagination } private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) public weak var delegate: RDEPUBReaderDelegate? public var configuration: RDEPUBReaderConfiguration { didSet { persistReaderSettingsIfNeeded() guard isViewLoaded else { return } let oldConfiguration = oldValue applyReaderViewConfiguration() if isExternalTextBook { if requiresRepagination(from: oldConfiguration, to: configuration) { rebuildExternalTextBook() } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { refreshVisibleContentPreservingLocation() } return } guard publication != nil else { return } if requiresRepagination(from: oldConfiguration, to: configuration) { repaginatePreservingCurrentLocation() } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { refreshVisibleContentPreservingLocation() } } } public var currentLocation: RDEPUBLocation? { currentVisibleLocation() } public var currentPageNumber: Int? { guard readerView.currentPage >= 0 else { return nil } return readerView.currentPage + 1 } public private(set) var currentSelection: RDEPUBSelection? public var highlights: [RDEPUBHighlight] { activeHighlights } public var bookmarks: [RDEPUBBookmark] { activeBookmarks } public var tableOfContents: [EPUBTableOfContentsItem] { publication?.tableOfContents ?? [] } public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] { guard let publication else { return [] } return flattenedTableOfContentsItems(from: publication.tableOfContents) } public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? { resolvedCurrentTableOfContentsItem() } private let epubURL: URL fileprivate let persistence: RDEPUBReaderPersistence? private let readerView = RDReaderView() private let loadingIndicator: UIActivityIndicatorView = { if #available(iOS 13.0, *) { return UIActivityIndicatorView(style: .large) } return UIActivityIndicatorView(style: .gray) }() private let errorLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 label.textAlignment = .center label.textColor = .darkGray label.isHidden = true return label }() private let paginationHostView = UIView() private var parser: RDEPUBParser? fileprivate var publication: RDEPUBPublication? private var readingSession: RDEPUBReadingSession? private var textBook: RDEPUBTextBook? private var activePages: [EPUBPage] = [] private var activeChapters: [EPUBChapterInfo] = [] fileprivate var activeBookmarks: [RDEPUBBookmark] = [] private var activeHighlights: [RDEPUBHighlight] = [] fileprivate lazy var topToolView = makeTopToolView() fileprivate lazy var bottomToolView = makeBottomToolView() fileprivate var currentBookIdentifier: String? private let textBookCache = RDEPUBTextBookCache() private var currentBrightness: CGFloat private var didStartInitialLoad = false private var isRepaginating = false private var paginationToken = UUID() private var paginator: RDEPUBPaginator? private var searchState: RDEPUBSearchState? private var lastAppliedViewportSignature: RDEPUBViewportSignature? private var pendingViewportChangeReason: RDEPUBViewportChangeReason? private var isWaitingForViewportTransitionCompletion = false /// 标记是否通过外部 TextBook 初始化(跳过 EPUB 解析流程) private var isExternalTextBook = false /// 外部 TextBook 的原始文件 URL(用于重建 TextBook) private var textFileURL: URL? public init( epubURL: URL, configuration: RDEPUBReaderConfiguration = .default, persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence() ) { let persistedSettings = persistence?.loadReaderSettings() self.epubURL = epubURL self.configuration = persistedSettings?.applying(to: configuration) ?? configuration self.persistence = persistence self.currentBrightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness))) super.init(nibName: nil, bundle: nil) UIScreen.main.brightness = currentBrightness } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程(用于 TXT 等纯文本文件) public convenience init( textBook: RDEPUBTextBook, bookIdentifier: String, title: String, textFileURL: URL? = nil, configuration: RDEPUBReaderConfiguration = .default, persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence() ) { let persistedSettings = persistence?.loadReaderSettings() let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration let brightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness))) self.init(epubURL: URL(string: "about:blank")!, configuration: resolvedConfig, persistence: persistence) self.isExternalTextBook = true self.textBook = textBook self.textFileURL = textFileURL self.currentBookIdentifier = bookIdentifier self.title = title self.didStartInitialLoad = true } public override func viewDidLoad() { super.viewDidLoad() UIScreen.main.brightness = currentBrightness view.backgroundColor = configuration.theme.contentBackgroundColor setupReaderView() setupLoadingIndicator() setupErrorLabel() delegate?.epubReader(self, configureTopToolView: topToolView) if isExternalTextBook { let restoreLocation = currentBookIdentifier.flatMap { persistence?.loadLocation(for: $0) } if let id = currentBookIdentifier { activeBookmarks = persistence?.loadBookmarks(for: id) ?? [] activeHighlights = persistence?.loadHighlights(for: id) ?? [] } finishPagination(restoreLocation: restoreLocation) } } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.interactivePopGestureRecognizer?.delegate = self navigationController?.interactivePopGestureRecognizer?.isEnabled = true } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startInitialLoadIfNeeded() } public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard let viewportSignature = currentViewportSignature() else { return } if !didStartInitialLoad { lastAppliedViewportSignature = viewportSignature startInitialLoadIfNeeded() return } guard publication != nil || isExternalTextBook else { lastAppliedViewportSignature = viewportSignature return } guard !isWaitingForViewportTransitionCompletion else { return } handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature) } public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) guard didStartInitialLoad else { return } isWaitingForViewportTransitionCompletion = true coordinator.animate(alongsideTransition: nil) { [weak self] _ in guard let self else { return } self.isWaitingForViewportTransitionCompletion = false self.view.layoutIfNeeded() self.handleViewportChangeIfNeeded(reason: .orientationTransition) } } public func reloadBook() { didStartInitialLoad = false parser = nil publication = nil readingSession = nil textBook = nil activePages = [] activeChapters = [] activeBookmarks = [] activeHighlights = [] searchState = nil lastAppliedViewportSignature = currentViewportSignature() pendingViewportChangeReason = nil isWaitingForViewportTransitionCompletion = false updateCurrentSelection(nil) readerView.reloadData() startInitialLoadIfNeeded() } public func go(to location: RDEPUBLocation) { guard publication != nil else { return } restoreReadingLocation(location) } @discardableResult public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { guard pageNumber > 0 else { return false } if textBook != nil { guard let location = resolvedTextLocation(forPageNumber: pageNumber) else { return false } return restoreReadingLocation(location, animated: animated) } guard activePages.indices.contains(pageNumber - 1) else { return false } readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) if let location = currentVisibleLocation() { persist(location: location) } return true } public func clearSelection() { updateCurrentSelection(nil) } public func bookmark(withID id: String) -> RDEPUBBookmark? { activeBookmarks.first { $0.id == id } } public func highlight(withID id: String) -> RDEPUBHighlight? { activeHighlights.first { $0.id == id } } public func nativeTextSemanticSummary() -> String? { guard let textBook, let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else { return nil } let metadata = page.metadata var parts = [ "page \(page.absolutePageIndex + 1)", "break \(metadata.breakReason.rawValue)", metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]", metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]", metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]" ].compactMap { $0 } if let firstDiagnostic = metadata.diagnostics.first { parts.append(firstDiagnostic) } return parts.joined(separator: " · ") } @discardableResult public func addHighlight( from selection: RDEPUBSelection? = nil, color: String = "#F8E16C", note: String? = nil ) -> RDEPUBHighlight? { addAnnotation(from: selection, style: .highlight, color: color, note: note) } @discardableResult public func addAnnotation( from selection: RDEPUBSelection? = nil, style: RDEPUBHighlightStyle, color: String = "#F8E16C", note: String? = nil ) -> RDEPUBHighlight? { let sourceSelection = selection ?? currentSelection guard let sourceSelection, let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil), !scopedSelection.isEmpty else { return nil } let newHighlight = RDEPUBHighlight( bookIdentifier: currentBookIdentifier, location: scopedSelection.location, text: scopedSelection.text, rangeInfo: scopedSelection.rangeInfo, style: style, color: color, note: note ) let isDuplicate = activeHighlights.contains { highlight in highlight.location.href == newHighlight.location.href && highlight.location.fragment == newHighlight.location.fragment && highlight.text == newHighlight.text && highlight.rangeInfo == newHighlight.rangeInfo && highlight.style == newHighlight.style } guard !isDuplicate else { return nil } activeHighlights.append(newHighlight) persistHighlightsAndRefreshContent() updateCurrentSelection(nil) return newHighlight } @discardableResult public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { guard let scopedHighlight = scopedHighlight(highlight) else { return nil } if let index = activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) { activeHighlights[index] = scopedHighlight } else { activeHighlights.append(scopedHighlight) } persistHighlightsAndRefreshContent() return scopedHighlight } @discardableResult public func removeHighlight(id: String) -> RDEPUBHighlight? { guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else { return nil } let removed = activeHighlights.remove(at: index) persistHighlightsAndRefreshContent() return removed } @discardableResult public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else { return nil } activeHighlights[index].note = normalizedNote(note) persistHighlightsAndRefreshContent() return activeHighlights[index] } @discardableResult public func go(toHighlightID id: String, animated: Bool = true) -> Bool { guard let highlight = highlight(withID: id) else { return false } return restoreReadingLocation(highlight.location, animated: animated) } public func removeAllHighlights() { guard !activeHighlights.isEmpty else { return } activeHighlights.removeAll() persistHighlightsAndRefreshContent() } @discardableResult public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool { go(toTableOfContentsHref: item.href, animated: animated) } @discardableResult public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool { go(toTableOfContentsHref: item.href, animated: animated) } @discardableResult public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool { guard publication != nil else { return false } let components = href.components(separatedBy: "#") let baseHref = components.first ?? href let fragment = components.count > 1 ? components[1] : nil let location = RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: baseHref, progression: 0, fragment: fragment ) return restoreReadingLocation(location, animated: animated) } private func setupReaderView() { readerView.dataSource = self readerView.delegate = self readerView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(readerView) NSLayoutConstraint.activate([ readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), readerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), readerView.topAnchor.constraint(equalTo: view.topAnchor), readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self)) readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self)) applyReaderViewConfiguration() } private func makeTopToolView() -> RDEPUBReaderTopToolView { let toolView = RDEPUBReaderTopToolView() toolView.onBack = { [weak self] in self?.handleBackAction() } toolView.onToggleBookmark = { [weak self] in _ = self?.toggleBookmark() } return toolView } private func makeBottomToolView() -> RDEPUBReaderBottomToolView { let toolView = RDEPUBReaderBottomToolView() toolView.onShowTableOfContents = { [weak self] in self?.presentTableOfContents() } toolView.onShowBookmarks = { [weak self] in self?.presentBookmarksManager() } toolView.onShowHighlights = { [weak self] in self?.presentHighlightsManager() } toolView.onAddHighlight = { [weak self] in self?.presentAnnotationCreation() } toolView.onShowSettings = { [weak self] in self?.presentSettings() } return toolView } private func setupLoadingIndicator() { loadingIndicator.hidesWhenStopped = true loadingIndicator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(loadingIndicator) NSLayoutConstraint.activate([ loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) } private func setupErrorLabel() { errorLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(errorLabel) NSLayoutConstraint.activate([ errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) } private func applyReaderViewConfiguration() { let displayTypeDidChange = readerView.currentDisplayType != configuration.displayType let preservedLocation = displayTypeDidChange ? currentVisibleLocation() : nil view.backgroundColor = configuration.theme.contentBackgroundColor readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled readerView.pageDirection = resolvedPageDirection() updateReaderChrome() if displayTypeDidChange { readerView.switchReaderDisplayType(configuration.displayType) if let preservedLocation { _ = restoreReadingLocation(preservedLocation) } } } private func startInitialLoadIfNeeded() { guard !didStartInitialLoad, readerView.bounds.width > 0, readerView.bounds.height > 0 else { return } didStartInitialLoad = true loadPublication() } private func loadPublication() { showLoading() let loadToken = UUID() paginationToken = loadToken DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } let parser = RDEPUBParser() do { try parser.parse(epubURL: self.epubURL) let publication = parser.makePublication() let bookIdentifier = parser.metadata.identifier ?? self.epubURL.lastPathComponent let restoreLocation = self.persistence?.loadLocation(for: bookIdentifier) let bookmarks = self.persistence?.loadBookmarks(for: bookIdentifier) ?? [] let highlights = self.persistence?.loadHighlights(for: bookIdentifier) ?? [] DispatchQueue.main.async { guard self.paginationToken == loadToken else { return } self.applyParsedPublication( parser: parser, publication: publication, bookIdentifier: bookIdentifier, restoreLocation: restoreLocation, bookmarks: bookmarks, highlights: highlights ) } } catch { DispatchQueue.main.async { guard self.paginationToken == loadToken else { return } self.handle(error: error) } } } } private func applyParsedPublication( parser: RDEPUBParser, publication: RDEPUBPublication, bookIdentifier: String, restoreLocation: RDEPUBLocation?, bookmarks: [RDEPUBBookmark], highlights: [RDEPUBHighlight] ) { self.parser = parser self.publication = publication self.currentBookIdentifier = bookIdentifier self.activeBookmarks = bookmarks self.activeHighlights = highlights self.readingSession = RDEPUBReadingSession(publication: publication) self.readingSession?.transition(to: .loading) self.title = parser.metadata.title.isEmpty ? epubURL.deletingPathExtension().lastPathComponent : parser.metadata.title applyReaderViewConfiguration() updateReaderChrome() delegate?.epubReader(self, didOpen: publication) paginatePublication(restoreLocation: restoreLocation) } private func paginatePublication(restoreLocation: RDEPUBLocation?) { guard let parser, let publication, let readingSession else { return } isRepaginating = true errorLabel.isHidden = true showLoading() let token = UUID() paginationToken = token if publication.readingProfile == .textReflowable { let renderer = resolvedTextRenderer() let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache) let pageSize = currentTextPageSize() let renderStyle = currentTextRenderStyle() DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } do { let textBook = try builder.build( parser: parser, publication: publication, pageSize: pageSize, style: renderStyle ) DispatchQueue.main.async { guard self.paginationToken == token else { return } self.applyTextBook(textBook, restoreLocation: restoreLocation) } } catch { DispatchQueue.main.async { guard self.paginationToken == token else { return } self.handle(error: error) } } } return } if publication.layout == .fixed { let snapshot = readingSession.makePaginationSnapshot( pageCounts: Array(repeating: 1, count: publication.spine.count), preferences: currentPreferences(), layoutContext: currentLayoutContext() ) applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) return } let paginator = RDEPUBPaginator() self.paginator = paginator paginator.calculate( parser: parser, hostingView: ensurePaginationHostView(), presentation: currentPreferences().presentationStyle(viewportSize: currentLayoutContext().viewportSize) ) { [weak self] pageCounts in guard let self = self, self.paginationToken == token else { return } let snapshot = readingSession.makePaginationSnapshot( pageCounts: pageCounts, preferences: self.currentPreferences(), layoutContext: self.currentLayoutContext() ) self.paginator = nil self.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) } } private func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { self.textBook = textBook let snapshot = nativeTextSnapshot(from: textBook) self.activePages = snapshot.pages self.activeChapters = snapshot.chapters readingSession?.setActiveSnapshot(snapshot) guard !textBook.pages.isEmpty else { handle(error: RDEPUBParserError.emptySpine) return } finishPagination(restoreLocation: restoreLocation) } private func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? ) { textBook = nil activePages = snapshot.pages activeChapters = snapshot.chapters readingSession?.setActiveSnapshot(snapshot) guard !snapshot.pages.isEmpty else { handle(error: RDEPUBParserError.emptySpine) return } finishPagination(restoreLocation: restoreLocation) } private func finishPagination(restoreLocation: RDEPUBLocation?) { isRepaginating = false hideLoading() readerView.reloadData() if let targetLocation = restoreLocation { restoreReadingLocation(targetLocation) } else { readerView.transitionToPage(pageNum: 0) readingSession?.transition(to: .idle) } if pendingViewportChangeReason != nil { let pendingReason = pendingViewportChangeReason ?? .pendingRepagination pendingViewportChangeReason = nil DispatchQueue.main.async { [weak self] in self?.handleViewportChangeIfNeeded(reason: pendingReason) } } } private func repaginatePreservingCurrentLocation() { guard publication != nil else { return } let restoreLocation = currentVisibleLocation() ?? persistenceLocation() paginatePublication(restoreLocation: restoreLocation) } @discardableResult fileprivate func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { guard let targetPageNumber = pageNumber(for: location) else { readerView.transitionToPage(pageNum: 0) readingSession?.transition(to: .idle) return false } if textBook == nil { _ = readingSession?.queueNavigation( to: location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) } else { readingSession?.transition(to: .jumping) } readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) return true } fileprivate func currentVisibleLocation() -> RDEPUBLocation? { if textBook != nil, readerView.currentPage >= 0 { return resolvedTextLocation(forPageNumber: readerView.currentPage + 1) } return readingSession?.currentReadingLocation(bookIdentifier: currentBookIdentifier) } private func persistenceLocation() -> RDEPUBLocation? { guard let currentBookIdentifier else { return nil } return persistence?.loadLocation(for: currentBookIdentifier) } private func persist(location: RDEPUBLocation) { guard let currentBookIdentifier else { return } persistence?.saveLocation(location, for: currentBookIdentifier) delegate?.epubReader(self, didUpdateLocation: location) delegate?.epubReader(self, didUpdateCurrentTableOfContentsItem: resolvedCurrentTableOfContentsItem()) updateBookmarkChrome() } private func updateCurrentSelection(_ selection: RDEPUBSelection?) { currentSelection = selection?.isEmpty == false ? selection : nil bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil) delegate?.epubReader(self, didChangeSelection: currentSelection) } private func scopedSelection( _ selection: RDEPUBSelection, relativeToSpineIndex spineIndex: Int? ) -> RDEPUBSelection? { guard let publication else { return nil } let normalizedLocation = publication.resourceResolver.normalizedLocation( selection.location, relativeToSpineIndex: spineIndex, bookIdentifier: currentBookIdentifier ) ?? RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: selection.location.href, progression: selection.location.progression, lastProgression: selection.location.lastProgression, fragment: selection.location.fragment, rangeAnchor: selection.location.rangeAnchor ) return RDEPUBSelection( bookIdentifier: currentBookIdentifier, location: normalizedLocation, text: selection.text, rangeInfo: selection.rangeInfo, createdAt: selection.createdAt ) } private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { guard let publication else { return nil } let normalizedLocation = publication.resourceResolver.normalizedLocation( highlight.location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: highlight.location.href, progression: highlight.location.progression, lastProgression: highlight.location.lastProgression, fragment: highlight.location.fragment, rangeAnchor: highlight.location.rangeAnchor ) return RDEPUBHighlight( id: highlight.id, bookIdentifier: currentBookIdentifier, location: normalizedLocation, text: highlight.text, rangeInfo: highlight.rangeInfo, style: highlight.style, color: highlight.color, note: highlight.note, createdAt: highlight.createdAt ) } private func persistHighlightsAndRefreshContent() { guard let currentBookIdentifier else { return } persistence?.saveHighlights(activeHighlights, for: currentBookIdentifier) delegate?.epubReader(self, didUpdateHighlights: activeHighlights) updateReaderChrome() refreshVisibleContentPreservingLocation() } private func refreshVisibleContentPreservingLocation() { let restoreLocation = currentVisibleLocation() ?? persistenceLocation() readerView.reloadData() if let restoreLocation { _ = restoreReadingLocation(restoreLocation) } } private func rebuildExternalTextBook() { guard let textFileURL else { return } let restoreLocation = currentVisibleLocation() ?? persistenceLocation() let pageSize = currentTextPageSize() let style = currentTextRenderStyle() let builder = RDPlainTextBookBuilder() if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) { applyTextBook(newBook, restoreLocation: restoreLocation) } } private func updateReaderChrome() { topToolView.apply(theme: configuration.theme) topToolView.setTitle(title ?? parser?.metadata.title ?? epubURL.deletingPathExtension().lastPathComponent) topToolView.setBookmarkEnabled(currentBookIdentifier != nil) bottomToolView.apply(theme: configuration.theme) bottomToolView.updateVisibility( showsTableOfContents: configuration.showsTableOfContents, allowsHighlights: configuration.allowsHighlights, showsSettingsPanel: configuration.showsSettingsPanel ) bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty) bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil) bottomToolView.setHighlightsEnabled(configuration.allowsHighlights && !activeHighlights.isEmpty) updateBookmarkChrome() } private func presentHighlightsManager() { guard configuration.allowsHighlights else { return } guard !activeHighlights.isEmpty else { return } let highlightsController = RDEPUBReaderHighlightsViewController( highlights: activeHighlights, theme: configuration.theme, sectionTitleProvider: { [weak self] highlight in self?.titleForHighlight(highlight) } ) highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in guard let self else { return } highlightsController?.dismiss(animated: true) { _ = self.go(to: highlight.location) } } highlightsController.onUpdateHighlight = { [weak self] highlight in _ = self?.updateHighlightNote(id: highlight.id, note: highlight.note) } highlightsController.onDeleteHighlight = { [weak self] highlight in _ = self?.removeHighlight(id: highlight.id) } let navigationController = UINavigationController(rootViewController: highlightsController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } private func presentAnnotationCreation() { guard configuration.allowsHighlights, let currentSelection else { return } presentAnnotationActionSheet(for: currentSelection) } private func presentAnnotationActionSheet(for selection: RDEPUBSelection) { let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in self?.createAnnotation(from: selection, style: .highlight) }) alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in self?.createAnnotation(from: selection, style: .underline) }) alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in self?.presentAnnotationNoteEditor(for: selection) }) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) if let popover = alert.popoverPresentationController { popover.sourceView = bottomToolView popover.sourceRect = bottomToolView.bounds } present(alert, animated: true) } private func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { guard let selection else { return } switch action { case .copy: UIPasteboard.general.string = selection.text updateCurrentSelection(nil) case .highlight: createAnnotation(from: selection, style: .highlight) case .annotate: presentAnnotationNoteEditor(for: selection) } } private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) { _ = addAnnotation(from: selection, style: style, note: note) } private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) { let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert) alert.addTextField { textField in textField.placeholder = "输入批注内容" } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in self?.createAnnotation( from: selection, style: .highlight, note: alert?.textFields?.first?.text ) }) present(alert, animated: true) } private func presentSettings() { guard configuration.showsSettingsPanel else { return } let settingsController = RDEPUBReaderSettingsViewController( configuration: configuration, brightness: currentBrightness ) settingsController.onBrightnessChange = { [weak self] brightness in self?.setScreenBrightness(brightness) } settingsController.onFontSizeChange = { [weak self] fontSize in self?.updateConfiguration { $0.fontSize = fontSize } } settingsController.onLineHeightChange = { [weak self] lineHeightMultiple in self?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple } } settingsController.onDisplayTypeChange = { [weak self] displayType in self?.updateConfiguration { $0.displayType = displayType } } settingsController.onThemeChange = { [weak self] theme in self?.updateConfiguration { $0.theme = theme } } let navigationController = UINavigationController(rootViewController: settingsController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? { guard let publication, let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { return nil } return flattenedTableOfContents.first { item in let rawHref = item.href.components(separatedBy: "#").first ?? item.href return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref }?.title } private func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) { var nextConfiguration = configuration update(&nextConfiguration) configuration = nextConfiguration } private func setScreenBrightness(_ brightness: CGFloat) { currentBrightness = max(0, min(1, brightness)) UIScreen.main.brightness = currentBrightness persistReaderSettingsIfNeeded() } private func persistReaderSettingsIfNeeded() { let settings = RDEPUBReaderSettings.capture( configuration: configuration, brightness: currentBrightness ) persistence?.saveReaderSettings(settings) } private func normalizedNote(_ note: String?) -> String? { let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } private func requiresRepagination( from oldConfiguration: RDEPUBReaderConfiguration, to newConfiguration: RDEPUBReaderConfiguration ) -> Bool { oldConfiguration.fontSize != newConfiguration.fontSize || oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple || oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets || oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset || oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit || oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode || oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine } private func requiresVisibleRefresh( from oldConfiguration: RDEPUBReaderConfiguration, to newConfiguration: RDEPUBReaderConfiguration ) -> Bool { oldConfiguration.theme != newConfiguration.theme } private func presentTableOfContents() { guard configuration.showsTableOfContents else { return } let items = flattenedTableOfContents guard !items.isEmpty else { return } let chapterController = RDEPUBReaderChapterListController( items: items, currentItem: currentTableOfContentsItem, theme: configuration.theme ) chapterController.onSelectItem = { [weak self] item in guard let self else { return } chapterController.dismiss(animated: true) { _ = self.go(toTableOfContentsItem: item, animated: true) } } let navigationController = UINavigationController(rootViewController: chapterController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } private func handleBackAction() { if let navigationController, navigationController.viewControllers.first != self { navigationController.popViewController(animated: true) return } dismiss(animated: true) } private func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? { let items = flattenedTableOfContents guard !items.isEmpty else { return nil } let currentPageNumber = max(readerView.currentPage + 1, 1) let pageAnchoredMatch = items.last { item in guard let pageNumber = item.pageNumber else { return false } return pageNumber <= currentPageNumber } if let pageAnchoredMatch { return pageAnchoredMatch } guard let publication, let currentLocation = currentVisibleLocation(), let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else { return nil } return items.last { item in guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else { return false } return normalizedItemHref == normalizedCurrentHref } } private func flattenedTableOfContentsItems( from items: [EPUBTableOfContentsItem], depth: Int = 0 ) -> [RDEPUBReaderTableOfContentsItem] { items.flatMap { item in let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0) let pageNumber: Int? if let textBook, let publication { pageNumber = textBook.pageNumber( for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier ) } else if let readingSession { pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 } } else { pageNumber = nil } let current = RDEPUBReaderTableOfContentsItem( title: item.title, href: item.href, depth: depth, pageNumber: pageNumber ) return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1) } } private func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size return RDEPUBNavigatorLayoutContext( containerSize: containerSize, pagesPerScreen: readerView.pagesPerScreen, safeAreaInsets: view.safeAreaInsets, userInterfaceIdiom: traitCollection.userInterfaceIdiom, reflowableContentInsets: configuration.reflowableContentInsets ) } private func currentPreferences() -> RDEPUBPreferences { configuration.makePreferences() } private func currentTextPageSize() -> CGSize { let viewportSize = currentLayoutContext().viewportSize let insets = configuration.reflowableContentInsets return CGSize( width: max(viewportSize.width - insets.left - insets.right, 1), height: max(viewportSize.height - insets.top - insets.bottom, 1) ) } private func currentTextRenderStyle() -> RDEPUBTextRenderStyle { let font = UIFont.systemFont(ofSize: configuration.fontSize) let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4) return RDEPUBTextRenderStyle( font: font, lineSpacing: lineSpacing, textColor: configuration.theme.contentTextColor, backgroundColor: configuration.theme.contentBackgroundColor ) } private func resolvedTextRenderer() -> RDEPUBTextRenderer { RDEPUBDTCoreTextRenderer() } private func resolvedPageDirection() -> RDReaderView.PageDirection { publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight } private func ensurePaginationHostView() -> UIView { let viewportSize = currentLayoutContext().viewportSize let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height) if paginationHostView.superview == nil { view.addSubview(paginationHostView) view.sendSubviewToBack(paginationHostView) } paginationHostView.frame = hostFrame return paginationHostView } private func request(for pageIndex: Int) -> RDEPUBRenderRequest? { guard let publication, activePages.indices.contains(pageIndex) else { return nil } let page = activePages[pageIndex] let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex) return currentPreferences().renderRequest( for: page, publication: publication, viewportSize: currentLayoutContext().viewportSize, targetLocation: pendingLocation, highlights: highlights(for: page), searchPresentation: searchPresentation(for: page) ) } private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] { guard let publication else { return [] } if let spread = page.fixedSpread { let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) }) return activeHighlights.filter { highlight in guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { return false } return hrefs.contains(normalizedHref) } } guard publication.spine.indices.contains(page.spineIndex) else { return [] } let href = publication.spine[page.spineIndex].href let normalizedHref = publication.resourceResolver.normalizedHref(href) return activeHighlights.filter { highlight in publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref } } private func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? { guard activePages.indices.contains(pageIndex) else { return nil } return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier) } private func handle(error: Error) { isRepaginating = false hideLoading() activePages = [] activeChapters = [] textBook = nil readerView.reloadData() errorLabel.text = error.localizedDescription errorLabel.isHidden = false delegate?.epubReader(self, didFailWithError: error) } private func showLoading() { errorLabel.isHidden = true loadingIndicator.startAnimating() } private func hideLoading() { loadingIndicator.stopAnimating() } private func currentViewportSignature() -> RDEPUBViewportSignature? { let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size guard containerSize.width > 0, containerSize.height > 0 else { return nil } let safeAreaInsets = view.safeAreaInsets return RDEPUBViewportSignature( width: containerSize.width, height: containerSize.height, safeTop: safeAreaInsets.top, safeLeft: safeAreaInsets.left, safeBottom: safeAreaInsets.bottom, safeRight: safeAreaInsets.right ) } private func handleViewportChangeIfNeeded( reason: RDEPUBViewportChangeReason, viewportSignature: RDEPUBViewportSignature? = nil ) { guard didStartInitialLoad, let signature = viewportSignature ?? currentViewportSignature() else { return } if isRepaginating { pendingViewportChangeReason = reason return } if let lastAppliedViewportSignature, !signature.differsSignificantly(from: lastAppliedViewportSignature) { return } lastAppliedViewportSignature = signature if isExternalTextBook { rebuildExternalTextBook() return } guard publication != nil else { return } repaginatePreservingCurrentLocation() } } extension RDEPUBReaderController { @discardableResult public func addBookmark(note: String? = nil) -> RDEPUBBookmark? { guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { return nil } guard bookmark(matching: location) == nil else { return nil } let newBookmark = RDEPUBBookmark( bookIdentifier: currentBookIdentifier, location: location, chapterTitle: titleForBookmarkLocation(location), note: normalizedBookmarkNote(note) ) activeBookmarks.append(newBookmark) persistBookmarksAndRefreshChrome() return newBookmark } @discardableResult public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { return nil } if let existingBookmark = bookmark(matching: location) { _ = removeBookmark(id: existingBookmark.id) return nil } return addBookmark(note: note) } @discardableResult public func removeBookmark(id: String) -> RDEPUBBookmark? { guard let index = activeBookmarks.firstIndex(where: { $0.id == id }) else { return nil } let removed = activeBookmarks.remove(at: index) persistBookmarksAndRefreshChrome() return removed } @discardableResult public func go(toBookmarkID id: String, animated: Bool = true) -> Bool { guard let bookmark = bookmark(withID: id) else { return false } return restoreReadingLocation(bookmark.location, animated: animated) } private func updateBookmarkChrome() { topToolView.setBookmarkSelected(currentBookmark() != nil) bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty) } private func presentBookmarksManager() { guard !activeBookmarks.isEmpty else { return } let bookmarksController = RDEPUBReaderBookmarksViewController( bookmarks: activeBookmarks, theme: configuration.theme ) bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in guard let self else { return } bookmarksController?.dismiss(animated: true) { _ = self.restoreReadingLocation(bookmark.location, animated: true) } } bookmarksController.onDeleteBookmark = { [weak self] bookmark in _ = self?.removeBookmark(id: bookmark.id) } let navigationController = UINavigationController(rootViewController: bookmarksController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? { if let currentLocation = currentVisibleLocation(), bookmarkHref(for: currentLocation) == bookmarkHref(for: location) { return currentTableOfContentsItem?.title } return flattenedTableOfContents.last { item in bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location) }?.title } private func persistBookmarksAndRefreshChrome() { guard let currentBookIdentifier else { return } persistence?.saveBookmarks(activeBookmarks, for: currentBookIdentifier) delegate?.epubReader(self, didUpdateBookmarks: activeBookmarks) updateBookmarkChrome() } private func currentBookmark() -> RDEPUBBookmark? { guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { return nil } return bookmark(matching: location) } private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? { guard let location else { return nil } return activeBookmarks.first { bookmarkMatchesLocation($0, location: location) } } private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool { guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else { return false } if let bookmarkAnchor = bookmark.location.rangeAnchor, let locationAnchor = location.rangeAnchor { return bookmarkAnchor == locationAnchor } if let bookmarkFragment = bookmark.location.fragment, let locationFragment = location.fragment { return bookmarkFragment == locationFragment } let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression) let threshold: Double = publication?.layout == .fixed ? 0.01 : 0.05 return progressionDelta <= threshold } private func bookmarkHref(for location: RDEPUBLocation) -> String { publication?.resourceResolver.normalizedHref(location.href) ?? location.href } private func bookmarkHref(forTableOfContentsHref href: String) -> String { let rawHref = href.components(separatedBy: "#").first ?? href return publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref } private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? { guard let location else { return nil } guard let publication else { return RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: location.href, progression: location.progression, lastProgression: location.lastProgression, fragment: location.fragment, rangeAnchor: location.rangeAnchor ) } return publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: location.href, progression: location.progression, lastProgression: location.lastProgression, fragment: location.fragment, rangeAnchor: location.rangeAnchor ) } private func normalizedBookmarkNote(_ note: String?) -> String? { let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } public func search(keyword: String) { let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { clearSearch() return } let matches = resolvedSearchMatches(for: normalizedKeyword) searchState = RDEPUBSearchState( keyword: normalizedKeyword, matches: matches, currentMatchIndex: matches.isEmpty ? nil : 0 ) notifySearchStateChanged() if matches.isEmpty { refreshVisibleContentPreservingLocation() } else { _ = navigateToCurrentSearchMatch(animated: false) } } @discardableResult public func searchNext() -> Bool { advanceSearch(by: 1) } @discardableResult public func searchPrevious() -> Bool { advanceSearch(by: -1) } public func clearSearch() { searchState = nil notifySearchStateChanged() refreshVisibleContentPreservingLocation() } private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] { if let textBook, let publication { return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword) } if let parser, let publication { return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) } return [] } private func advanceSearch(by delta: Int) -> Bool { guard var searchState, !searchState.matches.isEmpty else { return false } let currentIndex = searchState.currentMatchIndex ?? 0 let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count searchState.currentMatchIndex = nextIndex self.searchState = searchState notifySearchStateChanged() return navigateToCurrentSearchMatch(animated: true) } private func notifySearchStateChanged() { delegate?.epubReader(self, didUpdateSearchResult: searchState?.result) delegate?.epubReader(self, didChangeCurrentSearchMatch: searchState?.currentMatch) } private func navigateToCurrentSearchMatch(animated: Bool) -> Bool { guard let searchMatch = searchState?.currentMatch else { refreshVisibleContentPreservingLocation() return false } if let targetPageNumber = pageNumber(for: searchMatch), readerView.currentPage == targetPageNumber - 1 { refreshVisibleContentPreservingLocation() return true } let location = RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: searchMatch.href, progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, rangeAnchor: searchMatch.rangeAnchor ) return restoreReadingLocation(location, animated: animated) } private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { if let chapterData = textChapterData(forNormalizedHref: searchMatch.href) { let location = RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: chapterData.href, progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, rangeAnchor: searchMatch.rangeAnchor ) if let pageNumber = chapterData.pageNumber(for: location) { return pageNumber } if let rangeLocation = searchMatch.rangeLocation, let page = chapterData.pages.first(where: { rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset }) { return page.absolutePageIndex + 1 } } let location = RDEPUBLocation( bookIdentifier: currentBookIdentifier, href: searchMatch.href, progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, rangeAnchor: searchMatch.rangeAnchor ) if let textBook, let publication { return textBook.pageNumber( for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier ) } return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 } } private func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { guard let searchState, let publication else { return nil } let pageHrefs: [String] if let fixedSpread = page.fixedSpread { pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href } } else if publication.spine.indices.contains(page.spineIndex) { pageHrefs = [publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href) ?? publication.spine[page.spineIndex].href] } else { pageHrefs = [] } guard !pageHrefs.isEmpty else { return nil } let currentMatch = searchState.currentMatch let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href } let resources = pageHrefs.map { href in let matchCount = searchState.matches.filter { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href }.count let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil return RDEPUBSearchPresentationResource( href: href, matchCount: matchCount, activeLocalMatchIndex: activeLocalMatchIndex ) } return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources) } } extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { public func pageCountOfReaderView(readerView: RDReaderView) -> Int { textBook?.pages.count ?? activePages.count } public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { if let textBook, let page = textBook.page(at: pageNum + 1) { let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() contentView.delegate = self contentView.configure( page: page, pageNumber: pageNum + 1, totalPages: textBook.pages.count, configuration: configuration, highlights: textHighlights(for: page), searchState: searchState ) return contentView } guard let publication, let request = request(for: pageNum) else { return containerView ?? UIView() } let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView() contentView.delegate = self contentView.configure( publication: publication, request: request, pageNumber: pageNum + 1, totalPages: activePages.count, theme: configuration.theme ) return contentView } public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { textBook == nil ? NSStringFromClass(RDEPUBWebContentView.self) : NSStringFromClass(RDEPUBTextContentView.self) } private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] { if let textBook, let chapterData = textBook.chapterData(for: page.href) { return chapterData.highlights(on: page, from: activeHighlights) } guard let publication else { return activeHighlights.filter { $0.location.href == page.href } } let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href return activeHighlights.filter { (publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref } } private func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { guard let textBook, let publication else { return nil } let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href return textBook.chapters.lazy .first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref }) .flatMap { textBook.chapterData(for: $0.href) } } public func topToolView(readerView: RDReaderView) -> UIView? { topToolView } public func bottomToolView(readerView: RDReaderView) -> UIView? { bottomToolView } public func pageNum(readerView: RDReaderView, pageNum: Int) { updateCurrentSelection(nil) let totalPages = pageCountOfReaderView(readerView: readerView) if totalPages > 0, pageNum == totalPages - 1 { delegate?.epubReaderDidReachEnd(self) } if textBook != nil, let location = resolvedTextLocation(forPageNumber: pageNum + 1) { persist(location: location) synchronizeTextReadingState(pageNumber: pageNum + 1, location: location) return } if let location = fallbackLocation(for: pageNum) { persist(location: location) } if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving { readingSession?.transition(to: .idle) } } public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) { _ = isLandscape } } extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { guard readerView.currentPage >= 0, activePages.indices.contains(readerView.currentPage) else { return } let currentPage = activePages[readerView.currentPage] guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else { return } persist(location: location) readingSession?.updateReadingContext( pageNumber: readerView.currentPage + 1, location: location, spineIndex: spineIndex, chapterIndex: currentPage.chapterIndex, bookIdentifier: currentBookIdentifier ) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { if let selection { updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex)) } else { updateCurrentSelection(nil) } } func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { handleSelectionMenuAction(action, selection: currentSelection) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { guard let readingSession, let pageNumber = readingSession.queueNavigation( to: location, relativeToSpineIndex: fromSpineIndex, bookIdentifier: currentBookIdentifier ) else { return } readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { delegate?.epubReader(self, didActivateExternalLink: url) UIApplication.shared.open(url, options: [:], completionHandler: nil) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { print("EPUB JS Error: \(message)") } } extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { guard let selection else { updateCurrentSelection(nil) return } updateCurrentSelection(normalizedTextSelection(selection)) } func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { handleSelectionMenuAction(action, selection: currentSelection) contentView.clearSelection() } private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? { guard let textBook, let chapterData = textBook.chapterData(for: selection.location.href) else { return scopedSelection(selection, relativeToSpineIndex: nil) } guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else { return scopedSelection(selection, relativeToSpineIndex: nil) } let contentLength = max(chapterData.attributedContent.length, 1) let lastInclusiveOffset = max(contentLength - 1, 1) let start = max(0, min(payload.start, lastInclusiveOffset)) let endExclusive = max(start + 1, min(payload.end, contentLength)) let absoluteRange = NSRange(location: start, length: endExclusive - start) let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier) return RDEPUBSelection( bookIdentifier: currentBookIdentifier, location: location, text: selection.text, rangeInfo: selection.rangeInfo, createdAt: selection.createdAt ) } private func pageNumber(for location: RDEPUBLocation) -> Int? { if let textBook, let publication { // Anchor-based lookup (character-level precision) if let anchor = location.rangeAnchor?.start { if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) { return page + 1 } } let normalizedLocation = publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location return textBook.pageNumber( for: normalizedLocation, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier ) } return readingSession?.queueNavigation( to: location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) } private func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? { guard let textBook, let publication, let page = textBook.page(at: pageNumber) else { return nil } let location = textBook.chapterData(for: page.href)?.location(forPage: page, bookIdentifier: currentBookIdentifier) ?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier) guard let location else { return nil } return publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location } private func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) { guard let textBook, let page = textBook.page(at: pageNumber) else { readingSession?.transition(to: .idle) return } readingSession?.updateReadingContext( pageNumber: pageNumber, location: location, spineIndex: page.spineIndex, chapterIndex: page.chapterIndex, bookIdentifier: currentBookIdentifier ) } private func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> NativeTextSnapshot { let chapters = textBook.chapters.map { EPUBChapterInfo( spineIndex: $0.spineIndex, title: $0.title, pageCount: $0.pages.count ) } let pages = textBook.pages.map { EPUBPage( spineIndex: $0.spineIndex, chapterIndex: $0.chapterIndex, pageIndexInChapter: $0.pageIndexInChapter, totalPagesInChapter: $0.totalPagesInChapter, chapterTitle: $0.chapterTitle, fixedSpread: nil ) } return (pages, chapters) } } extension RDEPUBReaderController: UIGestureRecognizerDelegate { public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { true } }