diff --git a/ReadViewDemo/Podfile b/ReadViewDemo/Podfile index 1887048..c3171a7 100644 --- a/ReadViewDemo/Podfile +++ b/ReadViewDemo/Podfile @@ -10,7 +10,7 @@ end post_install do |installer| apply_settings = lambda do |config| config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end # Pods project (Pods.xcodeproj) diff --git a/ReadViewDemo/Podfile.lock b/ReadViewDemo/Podfile.lock index 163d73e..d745b64 100644 --- a/ReadViewDemo/Podfile.lock +++ b/ReadViewDemo/Podfile.lock @@ -37,9 +37,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 + RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb +PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json index 485cf9b..3d6d187 100644 --- a/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json +++ b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json @@ -9,12 +9,12 @@ "swift_versions": [ "5.10" ], - "homepage": "https://github.com/namesubai/RDEpubReaderView.git", + "homepage": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", "authors": { - "subai": "804663401@qq.com" + "shenlei": "shenlei@touchread.com" }, "source": { - "git": "https://github.com/namesubai/RDEpubReaderView.git", + "git": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", "tag": "0.0.1" }, "license": "MIT", diff --git a/ReadViewDemo/Pods/Manifest.lock b/ReadViewDemo/Pods/Manifest.lock index 163d73e..d745b64 100644 --- a/ReadViewDemo/Pods/Manifest.lock +++ b/ReadViewDemo/Pods/Manifest.lock @@ -37,9 +37,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 + RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb +PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj index d963c6e..2646709 100644 --- a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj +++ b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj @@ -2742,7 +2742,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = DTCoreText; INFOPLIST_FILE = "Target Support Files/DTCoreText/ResourceBundle-Resources-DTCoreText-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = Resources; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -2770,7 +2770,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2810,7 +2810,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTFoundation/DTFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2881,7 +2881,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2902,7 +2902,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = ZIPFoundation; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = ZIPFoundation_Privacy; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -2929,7 +2929,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2969,7 +2969,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3008,7 +3008,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTFoundation/DTFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3047,7 +3047,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3123,7 +3123,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -3155,7 +3155,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTCoreText/DTCoreText-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3195,7 +3195,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTCoreText/DTCoreText-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3224,7 +3224,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = DTCoreText; INFOPLIST_FILE = "Target Support Files/DTCoreText/ResourceBundle-Resources-DTCoreText-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = Resources; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3242,7 +3242,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = ZIPFoundation; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = ZIPFoundation_Privacy; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3260,7 +3260,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = RDEpubReaderView; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = RDEpubReaderViewAssets; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3278,7 +3278,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = RDEpubReaderView; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = RDEpubReaderViewAssets; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3306,7 +3306,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3345,7 +3345,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index 4546788..8bc3d23 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -329,14 +329,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n"; @@ -402,7 +398,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_MODULE_NAME = ReadViewDemoUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -427,7 +423,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_MODULE_NAME = ReadViewDemoUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -460,7 +456,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +495,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -571,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -628,7 +624,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift index 085a37f..e0f4e4e 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift @@ -56,4 +56,4 @@ public struct RDEPUBNavigatorLayoutContext: Equatable { insets.right = horizontalInsets return insets } -} \ No newline at end of file +} diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift index 1a20c05..5b2c1dd 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift @@ -11,6 +11,10 @@ public enum RDEPUBSafeArea { /// paddings, not approximations of the safe area itself. public static let minimumVerticalTextMargin: CGFloat = 20 + /// Compact top/bottom padding used by a landscape two-page spread. This + /// applies only beyond the actual safe area, which is always preserved. + public static let landscapeVerticalTextMargin: CGFloat = 12 + public static let minimumHorizontalTextMargin: CGFloat = 16 public static func keyWindow() -> UIWindow? { diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift index b8bbe85..4b69a9c 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift @@ -25,10 +25,22 @@ extension RDEPUBWebView { forMainFrameOnly: true ) ) + userContentController.addUserScript( + WKUserScript( + source: Self.readerPageMediaLifecycleUserScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.userContentController = userContentController + // Interactive fixed-layout EPUBs commonly start narration from their + // document-load timeline. Allow those page-owned audio elements to play + // without requiring an extra tap, and keep playback inside the reader. + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = [] let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) @@ -130,3 +142,110 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan target?.userContentController(userContentController, didReceive: message) } } + +extension RDEPUBWebView { + + /// Installed in every frame before EPUB scripts run. Hidden pages start in + /// a suspended state, including audio created with `new Audio()` that is + /// not attached to the DOM (as used by Tumult Hype publications). + static let readerPageMediaLifecycleUserScript = #""" + (() => { + if (window.__rdReaderMediaLifecycleInstalled) return; + window.__rdReaderMediaLifecycleInstalled = true; + + let isActive = false; + const trackedMedia = new Set(); + const command = "rd-reader-page-visibility"; + const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype; + const nativePlay = mediaPrototype && mediaPrototype.play; + + const register = media => { + if (media) trackedMedia.add(media); + return media; + }; + + const collectDocumentMedia = () => { + if (!document.querySelectorAll) return; + document.querySelectorAll("audio,video").forEach(register); + }; + + const setActive = active => { + isActive = !!active; + collectDocumentMedia(); + trackedMedia.forEach(media => { + if (isActive) { + if (media.__rdReaderReplayWhenVisible) { + media.__rdReaderReplayWhenVisible = false; + try { + media.currentTime = 0; + const result = nativePlay && nativePlay.call(media); + if (result && result.catch) result.catch(() => {}); + } catch (_) {} + } + } else { + if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) { + media.__rdReaderReplayWhenVisible = true; + } + if (!media.paused) { + try { media.pause(); } catch (_) {} + } + } + }); + }; + + if (mediaPrototype && nativePlay) { + mediaPrototype.play = function() { + register(this); + this.__rdReaderWasPlayed = true; + if (!isActive) { + this.__rdReaderReplayWhenVisible = true; + try { this.pause(); } catch (_) {} + return Promise.resolve(); + } + this.__rdReaderReplayWhenVisible = false; + return nativePlay.apply(this, arguments); + }; + } + + const NativeAudio = window.Audio; + if (typeof NativeAudio === "function") { + const ReaderAudio = function(source) { + const media = arguments.length > 0 ? new NativeAudio(source) : new NativeAudio(); + return register(media); + }; + ReaderAudio.prototype = NativeAudio.prototype; + try { Object.setPrototypeOf(ReaderAudio, NativeAudio); } catch (_) {} + window.Audio = ReaderAudio; + } + + window.addEventListener("message", event => { + const message = event.data; + if (!message || message.type !== command) return; + setActive(message.active); + for (let index = 0; index < window.frames.length; index += 1) { + try { window.frames[index].postMessage(message, "*"); } catch (_) {} + } + }); + + document.addEventListener("DOMContentLoaded", () => setActive(isActive)); + if (window.MutationObserver) { + new MutationObserver(() => setActive(isActive)).observe(document, { + childList: true, + subtree: true + }); + } + })(); + """# + + func setReaderPageVisible(_ isVisible: Bool) { + isReaderPageVisible = isVisible + applyReaderPageMediaVisibility() + } + + func applyReaderPageMediaVisibility() { + guard let webView else { return } + let active = isReaderPageVisible ? "true" : "false" + let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');" + webView.evaluateJavaScript(script, completionHandler: nil) + } +} diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift index 428f5a3..0e3a197 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift @@ -31,6 +31,7 @@ extension RDEPUBWebView: WKNavigationDelegate { public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) + applyReaderPageMediaVisibility() applyPresentation() } diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift index 9563b24..0f99e51 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift @@ -136,6 +136,8 @@ public final class RDEPUBWebView: UIView { var isFixedLayout = false + var isReaderPageVisible = true + var pendingProgressionRequest = false var fixedLayoutReadyWorkItem: DispatchWorkItem? @@ -186,6 +188,7 @@ public final class RDEPUBWebView: UIView { pendingHighlights = [] fixedSpread = nil isFixedLayout = false + isReaderPageVisible = true pendingProgressionRequest = false didRenderCurrentRequest = false teardownWebView() diff --git a/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html index 8855696..8e7cd24 100644 --- a/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html +++ b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html @@ -55,7 +55,6 @@ "#, with: "", options: .regularExpression) - .replacingOccurrences(of: #"" - if html.range(of: "", options: .caseInsensitive) != nil { - return html.replacingOccurrences(of: "", with: styleTag + "", options: .caseInsensitive) - } - if html.range(of: "
String { - let values = cssValues(for: presentation) - let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" - let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" - let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : "" - - return """ - html { - margin: 0 !important; - padding: 0 !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow-x: hidden !important; - overflow-y: hidden !important; - } - body { - margin: 0 !important; - padding: 0 !important; - box-sizing: border-box !important; - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - background: transparent !important; - } - #ss-reader-viewport { - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - background: transparent !important; - } - #ss-reader-content { - position: relative !important; - box-sizing: border-box !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: visible !important; - padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; - font-size: \(values.fontSize)px !important; - line-height: \(values.lineHeight) !important; - -webkit-column-width: \(values.contentWidth)px !important; - column-width: \(values.contentWidth)px !important; - \(columnCountCSS) - -webkit-column-gap: \(values.columnGap)px !important; - column-gap: \(values.columnGap)px !important; - -webkit-column-fill: auto !important; - column-fill: auto !important; - transform-origin: top left !important; - will-change: transform !important; - \(backgroundCSS) - \(textCSS) - } - #ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe { - max-width: 100% !important; - height: auto !important; - break-inside: avoid-column !important; - -webkit-column-break-inside: avoid !important; - } - #ss-reader-content mark.ss-reader-highlight { - padding: 0; - border-radius: 2px; - color: inherit !important; - } - #ss-reader-content mark.ss-reader-highlight-highlight { - text-decoration: none !important; - color: inherit !important; - } - #ss-reader-content mark.ss-reader-highlight-underline { - background: transparent !important; - color: inherit !important; - text-decoration-line: underline !important; - text-decoration-thickness: 2px !important; - text-decoration-skip-ink: auto !important; - } - """ - } - - public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String { - let values = cssValues(for: presentation) - let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" - let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" - let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : "" - - return """ - html { - margin: 0 !important; - padding: 0 !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - } - body { - margin: 0 !important; - box-sizing: border-box !important; - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: visible !important; - padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; - font-size: \(values.fontSize)px !important; - line-height: \(values.lineHeight) !important; - -webkit-column-width: \(values.contentWidth)px !important; - column-width: \(values.contentWidth)px !important; - \(columnCountCSS) - -webkit-column-gap: \(values.columnGap)px !important; - column-gap: \(values.columnGap)px !important; - -webkit-column-fill: auto !important; - column-fill: auto !important; - transform-origin: top left !important; - \(backgroundCSS) - \(textCSS) - } - img, svg, video, canvas, iframe { - max-width: 100% !important; - height: auto !important; - break-inside: avoid-column !important; - -webkit-column-break-inside: avoid !important; - } - """ - } - - public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String { - let style = measurementCSS(for: presentation) - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "`", with: "\\`") - - let viewportWidth = Double(max(1, presentation.viewportSize.width)) - let viewportWidthJS = String(format: "%.3f", viewportWidth) - - return """ - (function() { - var style = document.getElementById('ss-reader-pagination'); - if (!style) { - style = document.createElement('style'); - style.id = 'ss-reader-pagination'; - document.head.appendChild(style); - } - style.textContent = `\(style)`; - var scrollingElement = document.scrollingElement || document.documentElement || document.body; - var totalWidth = Math.max( - scrollingElement ? scrollingElement.scrollWidth : 0, - scrollingElement ? scrollingElement.offsetWidth : 0, - document.body ? document.body.scrollWidth : 0, - document.body ? document.body.offsetWidth : 0, - document.documentElement ? document.documentElement.scrollWidth : 0, - document.documentElement ? document.documentElement.offsetWidth : 0 - ); - return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS))); - })(); - """ - } - - private static func cssValues(for presentation: RDEPUBPresentationStyle) -> ( - viewportWidth: String, - viewportHeight: String, - contentWidth: String, - columnGap: String, - numberOfColumns: Int, - paddingTop: String, - paddingRight: String, - paddingBottom: String, - paddingLeft: String, - fontSize: String, - lineHeight: String - ) { - let viewportWidth = max(1, presentation.viewportSize.width) - let viewportHeight = max(1, presentation.viewportSize.height) - let contentInsets = presentation.contentInsets - let numberOfColumns = max(1, presentation.numberOfColumns) - let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right) - let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap) - let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns)) - let columnGap = max(0, presentation.columnGap) - - return ( - viewportWidth: String(format: "%.3f", viewportWidth), - viewportHeight: String(format: "%.3f", viewportHeight), - contentWidth: String(format: "%.3f", contentWidth), - columnGap: String(format: "%.3f", columnGap), - numberOfColumns: numberOfColumns, - paddingTop: String(format: "%.3f", contentInsets.top), - paddingRight: String(format: "%.3f", contentInsets.right), - paddingBottom: String(format: "%.3f", contentInsets.bottom), - paddingLeft: String(format: "%.3f", contentInsets.left), - fontSize: String(format: "%.3f", presentation.fontSize), - lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple)) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift deleted file mode 100644 index 10417ba..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift +++ /dev/null @@ -1,76 +0,0 @@ - -import Foundation - -public struct RDEPUBTextAnchor: Codable, Equatable { - - public let fileIndex: Int - - public let row: Int - - public let column: Int - - public let chapterOffset: Int - - public let fragmentID: String? - - public var spineIndex: Int { fileIndex } - - public init( - fileIndex: Int, - row: Int, - column: Int, - chapterOffset: Int, - fragmentID: String? = nil - ) { - self.fileIndex = fileIndex - self.row = row - self.column = column - self.chapterOffset = chapterOffset - self.fragmentID = fragmentID - } - - enum CodingKeys: String, CodingKey { - case fileIndex - case spineIndex - case row - case column - case chapterOffset - case fragmentID - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedFileIndex = try container.decodeIfPresent(Int.self, forKey: .fileIndex) - ?? container.decode(Int.self, forKey: .spineIndex) - self.fileIndex = decodedFileIndex - self.row = try container.decodeIfPresent(Int.self, forKey: .row) ?? 0 - self.column = try container.decodeIfPresent(Int.self, forKey: .column) ?? 0 - self.chapterOffset = try container.decode(Int.self, forKey: .chapterOffset) - self.fragmentID = try container.decodeIfPresent(String.self, forKey: .fragmentID) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(fileIndex, forKey: .fileIndex) - try container.encode(row, forKey: .row) - try container.encode(column, forKey: .column) - try container.encode(chapterOffset, forKey: .chapterOffset) - try container.encodeIfPresent(fragmentID, forKey: .fragmentID) - } -} - -public struct RDEPUBTextRangeAnchor: Codable, Equatable { - - public let start: RDEPUBTextAnchor - - public let end: RDEPUBTextAnchor - - public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) { - self.start = start - self.end = end - } - - public var nsRange: NSRange { - NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0)) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift deleted file mode 100644 index 7ca2286..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift +++ /dev/null @@ -1,109 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - func configureWebViewIfNeeded(publication: RDEPUBPublication) { - let parser = publication.parser - let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString - if publicationKey == configuredPublicationKey, webView != nil { - return - } - - teardownWebView() - - let userContentController = WKUserContentController() - RDEPUBJavaScriptBridge.messageNames.forEach { - userContentController.add(self, name: $0) - } - userContentController.addUserScript( - WKUserScript( - source: RDEPUBJavaScriptBridge.userScript, - injectionTime: .atDocumentEnd, - forMainFrameOnly: true - ) - ) - - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .nonPersistent() - configuration.userContentController = userContentController - let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) - configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) - - let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration) - webView.onSelectionAction = { [weak self] action in - guard let self else { return } - self.handleSelectionMenuAction(action) - } - // M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController - if #available(iOS 16.0, *) { - let interaction = UIEditMenuInteraction(delegate: webView) - webView.addInteraction(interaction) - webView._editMenuInteraction = interaction - } else { - UIMenuController.shared.menuItems = [ - UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))), - UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))), - UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:))) - ] - } - webView.navigationDelegate = self - webView.scrollView.isScrollEnabled = false - webView.scrollView.showsHorizontalScrollIndicator = false - webView.scrollView.showsVerticalScrollIndicator = false - webView.scrollView.bounces = false - webView.scrollView.alwaysBounceHorizontal = false - webView.scrollView.alwaysBounceVertical = false - webView.scrollView.clipsToBounds = true - webView.scrollView.panGestureRecognizer.isEnabled = false - webView.scrollView.pinchGestureRecognizer?.isEnabled = false - webView.isOpaque = false - webView.backgroundColor = .clear - webView.clipsToBounds = true - if #available(iOS 16.4, *) { - webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled - } - - addSubview(webView) - webView.frame = bounds - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - self.schemeHandler = schemeHandler - self.webView = webView - self.configuredPublicationKey = publicationKey - RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") - } - - func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { - delegate?.epubWebView(self, didRequestSelectionAction: action) - clearWebSelection() - } - - func clearWebSelection() { - webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") - } - - func teardownWebView() { - cancelFixedLayoutReadyFallback() - if let webView { - RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))") - } - // M-15: Clean up UIEditMenuInteraction on iOS 16+ - if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction { - webView?.removeInteraction(interaction) - } - webView?.navigationDelegate = nil - if let userContentController = webView?.configuration.userContentController { - RDEPUBJavaScriptBridge.messageNames.forEach { - userContentController.removeScriptMessageHandler(forName: $0) - } - } - (webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil - webView?.removeFromSuperview() - webView = nil - schemeHandler = nil - configuredPublicationKey = nil - currentLoadSignature = nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift deleted file mode 100644 index 1458105..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift +++ /dev/null @@ -1,122 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - public func loadFixedSpread( - parser: RDEPUBParser, - spread: EPUBFixedSpread, - viewportSize: CGSize, - contentInset: UIEdgeInsets, - backgroundColor: UIColor? - ) { - let request = RDEPUBRenderRequest.fixed( - RDEPUBFixedRenderRequest( - spread: spread, - viewportSize: viewportSize, - contentInset: contentInset, - backgroundColorCSS: backgroundColor?.rd_hexString, - fit: .page, - searchPresentation: nil - ) - ) - load(publication: parser.makePublication(), request: request) - } - - func handleFixedLayoutLoad( - publication: RDEPUBPublication, - request: RDEPUBFixedRenderRequest, - loadSignature: String, - in webView: WKWebView - ) { - currentPageIndex = 0 - currentTotalPagesInChapter = 1 - viewportSize = request.viewportSize - currentPadding = request.contentInset - currentFontSize = 16 - currentLineHeightMultiple = 1.5 - currentThemeBackgroundColor = request.backgroundColorCSS - currentThemeTextColor = nil - targetLocation = nil - pendingHighlights = [] - fixedSpread = request.spread - isFixedLayout = true - pendingProgressionRequest = false - currentLoadSignature = loadSignature - scheduleFixedLayoutReadyFallback() - - let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication) - RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)") - webView.loadHTMLString( - html, - baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/") - ) - } - - func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String { - let searchSignature = [ - request.searchPresentation?.keyword ?? "", - request.searchPresentation?.resources.map { - [ - $0.href, - String($0.matchCount), - String($0.activeLocalMatchIndex ?? -1) - ].joined(separator: "|") - }.joined(separator: ",") ?? "" - ].joined(separator: "#") - return [ - publicationKey, - "fixed", - request.spread.resources.map(\.href).joined(separator: "|"), - String(format: "%.2f", request.viewportSize.width), - String(format: "%.2f", request.viewportSize.height), - String(format: "%.2f", request.contentInset.top), - String(format: "%.2f", request.contentInset.left), - String(format: "%.2f", request.contentInset.bottom), - String(format: "%.2f", request.contentInset.right), - request.backgroundColorCSS ?? "", - request.fit.rawValue, - searchSignature - ].joined(separator: "#") - } - - func scheduleFixedLayoutReadyFallback() { - let workItem = DispatchWorkItem { [weak self] in - RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired") - self?.applyFixedPresentationIfNeeded { - self?.refreshNativeDecorationsIfNeeded { - self?.rendered() - } - } - } - fixedLayoutReadyWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) - } - - func cancelFixedLayoutReadyFallback() { - fixedLayoutReadyWorkItem?.cancel() - fixedLayoutReadyWorkItem = nil - } - - func applyFixedPresentationIfNeeded(completion: (() -> Void)? = nil) { - guard let webView, - let currentRenderRequest, - case .fixed(let request) = currentRenderRequest else { - completion?() - return - } - - let script = RDEPUBJavaScriptBridge.applyFixedPresentationScript(for: request) - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - } - self.applySearchDecorationsIfNeeded(completion: completion) - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift deleted file mode 100644 index 428f5a3..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift +++ /dev/null @@ -1,149 +0,0 @@ - -import Foundation -import WebKit - -extension RDEPUBWebView { - - func internalLocation(from url: URL) -> RDEPUBLocation? { - let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" - let fragment = url.fragment - guard !href.isEmpty || fragment != nil else { return nil } - return RDEPUBLocation(href: href, progression: 0, fragment: fragment) - } - - func currentLocation(from body: [String: Any]) -> RDEPUBLocation { - let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0 - let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue - let fragment = body["fragment"] as? String - let href = body["href"] as? String ?? currentHref - return RDEPUBLocation(href: href, progression: progression, lastProgression: lastProgression, fragment: fragment) - } -} - -extension RDEPUBWebView: WKNavigationDelegate { - public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) - } - - public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didCommit", url: webView.url) - } - - public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) - applyPresentation() - } - - public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) - } - - public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) - } - - public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) - } - - public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url) - guard navigationAction.navigationType == .linkActivated, - let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - if url.scheme == RDEPUBResourceURLSchemeHandler.scheme, - let location = internalLocation(from: url) { - delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) - decisionHandler(.cancel) - return - } - - if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) { - delegate?.epubWebView(self, didActivateExternalLink: url) - decisionHandler(.cancel) - return - } - - decisionHandler(.allow) - } -} - -extension RDEPUBWebView: WKScriptMessageHandler { - - public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body) - switch message.name { - case RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue: - guard let body = message.body as? [String: Any] else { return } - delegate?.epubWebView(self, didUpdateLocation: currentLocation(from: body), spineIndex: currentSpineIndex) - case RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue: - if let body = message.body as? [String: Any], - let text = body["text"] as? String, - let rangeInfo = body["rangeInfo"] as? String { - let location = currentLocation(from: body) - let selection = RDEPUBSelection(location: location, text: text, rangeInfo: rangeInfo) - delegate?.epubWebView(self, didChangeSelection: selection, spineIndex: currentSpineIndex) - } else { - delegate?.epubWebView(self, didChangeSelection: nil, spineIndex: currentSpineIndex) - } - case RDEPUBJavaScriptBridgeMessage.internalLink.rawValue: - guard let body = message.body as? [String: Any], - let href = body["href"] as? String else { return } - let location = RDEPUBLocation(href: href, progression: 0) - delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) - case RDEPUBJavaScriptBridgeMessage.externalLink.rawValue: - guard let body = message.body as? [String: Any], - let urlString = body["url"] as? String, - let url = URL(string: urlString) else { return } - delegate?.epubWebView(self, didActivateExternalLink: url) - case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue: - delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)") - case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue: - applyFixedPresentationIfNeeded { - self.refreshNativeDecorationsIfNeeded { - self.rendered() - } - } - case RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue: - guard let body = message.body as? [String: Any], - let src = body["src"] as? String else { return } - let sourceRect = sourceRect(from: body["rect"]) - delegate?.epubWebView(self, didTapImageWithSource: src, sourceRect: sourceRect) - case RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue: - guard let body = message.body as? [String: Any], - let altText = body["alt"] as? String else { return } - let sourceRect = sourceRect(from: body["rect"]) - delegate?.epubWebView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect) - default: - break - } - } - - private func sourceRect(from value: Any?) -> CGRect? { - guard let rectDict = value as? [String: Any] else { return nil } - return CGRect( - x: cgFloatValue(rectDict["x"]), - y: cgFloatValue(rectDict["y"]), - width: cgFloatValue(rectDict["width"]), - height: cgFloatValue(rectDict["height"]) - ) - } - - private func cgFloatValue(_ value: Any?) -> CGFloat { - switch value { - case let number as NSNumber: - return CGFloat(truncating: number) - case let double as Double: - return CGFloat(double) - case let int as Int: - return CGFloat(int) - case let string as String: - return CGFloat(Double(string) ?? 0) - default: - return 0 - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift deleted file mode 100644 index 472304a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift +++ /dev/null @@ -1,161 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - public func loadPage( - parser: RDEPUBParser, - spineIndex: Int, - pageIndex: Int, - totalPagesInChapter: Int, - viewportSize: CGSize, - padding: UIEdgeInsets, - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - themeBackgroundColor: String?, - themeTextColor: String?, - targetLocation: RDEPUBLocation?, - highlights: [RDEPUBHighlight] - ) { - let href = parser.spine.indices.contains(spineIndex) ? parser.spine[spineIndex].href : "" - let request = RDEPUBRenderRequest.reflowable( - RDEPUBReflowableRenderRequest( - spineIndex: spineIndex, - href: href, - pageIndex: pageIndex, - totalPagesInChapter: totalPagesInChapter, - presentation: RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: padding, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple, - themeBackgroundColor: themeBackgroundColor, - themeTextColor: themeTextColor - ), - targetLocation: targetLocation, - highlights: highlights, - searchPresentation: nil - ) - ) - load(publication: parser.makePublication(), request: request) - } - - func handleReflowableLoad( - publication: RDEPUBPublication, - request: RDEPUBReflowableRenderRequest, - loadSignature: String, - in webView: WKWebView - ) { - guard publication.spine.indices.contains(request.spineIndex), - let requestURL = publication.resourceResolver.resourceURL(forRelativePath: request.href) else { - return - } - - currentPageIndex = request.pageIndex - currentTotalPagesInChapter = max(1, request.totalPagesInChapter) - viewportSize = request.presentation.viewportSize - currentPadding = request.presentation.contentInsets - currentFontSize = request.presentation.fontSize - currentLineHeightMultiple = request.presentation.lineHeightMultiple - currentThemeBackgroundColor = request.presentation.themeBackgroundColor - currentThemeTextColor = request.presentation.themeTextColor - targetLocation = request.targetLocation - pendingHighlights = request.highlights - fixedSpread = nil - isFixedLayout = false - pendingProgressionRequest = true - - if currentLoadSignature == loadSignature { - if didRenderCurrentRequest || webView.isLoading { - let reason = didRenderCurrentRequest ? "already-rendered" : "still-loading" - RDEPUBWebViewDebug.log(debugScope, message: "skip duplicate request href=\(request.href) page=\(request.pageIndex) reason=\(reason)") - return - } - - if webView.url == requestURL { - RDEPUBWebViewDebug.log(debugScope, message: "reuse existing document href=\(request.href) page=\(request.pageIndex)") - applyPresentation() - return - } - } - - currentLoadSignature = loadSignature - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "load-request", url: requestURL) - webView.load(URLRequest(url: requestURL)) - } - - func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String { - let targetSignature = [ - request.targetLocation?.href ?? "", - String(request.targetLocation?.progression ?? 0), - String(request.targetLocation?.lastProgression ?? 0), - request.targetLocation?.fragment ?? "", - request.targetHighlightRangeInfo ?? "" - ].joined(separator: "|") - let highlightSignature = request.highlights - .map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") } - .joined(separator: ",") - let searchSignature = [ - request.searchPresentation?.keyword ?? "", - request.searchPresentation?.resources.map { - [ - $0.href, - String($0.matchCount), - String($0.activeLocalMatchIndex ?? -1) - ].joined(separator: "|") - }.joined(separator: ",") ?? "" - ].joined(separator: "#") - let presentation = request.presentation - return [ - publicationKey, - "reflowable", - String(request.spineIndex), - request.href, - String(request.pageIndex), - String(request.totalPagesInChapter), - String(format: "%.2f", presentation.viewportSize.width), - String(format: "%.2f", presentation.viewportSize.height), - String(format: "%.2f", presentation.contentInsets.top), - String(format: "%.2f", presentation.contentInsets.left), - String(format: "%.2f", presentation.contentInsets.bottom), - String(format: "%.2f", presentation.contentInsets.right), - String(format: "%.2f", presentation.fontSize), - String(format: "%.2f", presentation.lineHeightMultiple), - String(presentation.numberOfColumns), - String(format: "%.2f", presentation.columnGap), - presentation.themeBackgroundColor ?? "", - presentation.themeTextColor ?? "", - targetSignature, - highlightSignature, - searchSignature - ].joined(separator: "#") - } - - func applyPresentation() { - guard let webView, let currentRenderRequest else { return } - guard case .reflowable(let request) = currentRenderRequest else { - return - } - - let script = RDEPUBJavaScriptBridge.applyPresentationScript(for: request) - RDEPUBWebViewDebug.logJavaScript( - debugScope, - webView: webView, - action: "applyPresentation", - details: "spine=\(request.spineIndex) page=\(request.pageIndex) total=\(request.totalPagesInChapter)" - ) - - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { return } - if let error { - RDEPUBWebViewDebug.logJavaScript(self.debugScope, webView: webView, action: "applyPresentationFailed", details: error.localizedDescription) - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - return - } - self.applySearchDecorationsIfNeeded { - self.rendered() - } - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift deleted file mode 100644 index d1d2c05..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift +++ /dev/null @@ -1,142 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - private var normalSearchOverlayColor: UIColor { - UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16) - } - - private var activeSearchOverlayColor: UIColor { - UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34) - } - - func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { - guard let webView else { - completion?() - return - } - - let presentation: RDEPUBSearchPresentation? - switch currentRenderRequest { - case .reflowable(let request): - presentation = request.searchPresentation - case .fixed(let request): - presentation = request.searchPresentation - case .none: - presentation = nil - } - - let script = RDEPUBJavaScriptBridge.applySearchScript(for: presentation) - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - } - self.refreshNativeDecorationsIfNeeded(completion: completion) - } - } - - func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) { - guard let webView else { - onDecorationsResolved?([]) - completion?() - return - } - - webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - self.onDecorationsResolved?([]) - completion?() - return - } - - let decorations = self.nativeDecorations(from: result) - self.onDecorationsResolved?(decorations) - completion?() - } - } - - private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] { - guard let payload = result as? [String: Any] else { return [] } - - let highlightDecorations = decorationList( - from: payload["highlights"] as? [[String: Any]], - defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45) - ) - let searchDecorations = decorationList( - from: payload["search"] as? [[String: Any]], - defaultColor: normalSearchOverlayColor - ) - return highlightDecorations + searchDecorations - } - - private func decorationList( - from items: [[String: Any]]?, - defaultColor: UIColor - ) -> [RDEPUBTextOverlayDecoration] { - guard let items else { return [] } - - return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in - guard let kindString = item["kind"] as? String, - let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString), - let rectPayloads = item["rects"] as? [[String: Any]] else { - return nil - } - - let rects = rectPayloads.compactMap { rectPayload -> CGRect? in - guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue, - let y = (rectPayload["y"] as? NSNumber)?.doubleValue, - let width = (rectPayload["width"] as? NSNumber)?.doubleValue, - let height = (rectPayload["height"] as? NSNumber)?.doubleValue else { - return nil - } - return CGRect(x: x, y: y, width: width, height: height) - } - guard !rects.isEmpty else { return nil } - - let color: UIColor - switch kind { - case .underline: - let hex = item["color"] as? String ?? "#F8E16C" - color = overlayColor(hex: hex, alpha: 1) ?? defaultColor - case .activeSearch: - color = activeSearchOverlayColor - case .search: - color = normalSearchOverlayColor - case .highlight: - let hex = item["color"] as? String ?? "#F8E16C" - color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor - case .selection, .locate: - color = defaultColor - } - - return RDEPUBTextOverlayDecoration( - kind: kind, - absoluteRange: NSRange(location: index, length: 1), - rects: rects, - color: color - ) - } - } - - private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? { - var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) - value = value.replacingOccurrences(of: "#", with: "") - guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil } - return UIColor( - red: CGFloat((hexValue >> 16) & 0xFF) / 255, - green: CGFloat((hexValue >> 8) & 0xFF) / 255, - blue: CGFloat(hexValue & 0xFF) / 255, - alpha: alpha - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift deleted file mode 100644 index ae28125..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift +++ /dev/null @@ -1,244 +0,0 @@ - -import UIKit -import WebKit - -public protocol RDEPUBWebViewDelegate: AnyObject { - - func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) - - func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) - - func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) - - func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) - - func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) - - func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) -} - -public extension RDEPUBWebViewDelegate { - func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} - func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {} - func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {} -} - -final class RDEPUBAnnotationWebView: WKWebView { - - var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? - - // M-15: Backing store for UIEditMenuInteraction (iOS 16+). - // Stored as Any? to avoid @available on stored property restriction. - var _editMenuInteraction: Any? - - override var canBecomeFirstResponder: Bool { - true - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - switch action { - case #selector(rd_copy(_:)), - #selector(rd_highlight(_:)), - #selector(rd_annotate(_:)): - return true - default: - return false - } - } - - @objc func rd_copy(_ sender: Any?) { - onSelectionAction?(.copy) - } - - @objc func rd_highlight(_ sender: Any?) { - onSelectionAction?(.highlight) - } - - @objc func rd_annotate(_ sender: Any?) { - onSelectionAction?(.annotate) - } -} - -// MARK: - UIEditMenuInteractionDelegate (iOS 16+) - -@available(iOS 16.0, *) -extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate { - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - menuFor configuration: UIEditMenuConfiguration - ) -> UIMenu { - UIMenu(children: [ - UICommand(title: "拷贝", action: #selector(rd_copy(_:))), - UICommand(title: "高亮", action: #selector(rd_highlight(_:))), - UICommand(title: "批注", action: #selector(rd_annotate(_:))) - ]) - } - - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - targetRectFor configuration: UIEditMenuConfiguration - ) -> CGRect { - bounds - } -} - -public final class RDEPUBWebView: UIView { - - public weak var delegate: RDEPUBWebViewDelegate? - - public var onRendered: (() -> Void)? - - var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)? - - var publication: RDEPUBPublication? - - var currentRenderRequest: RDEPUBRenderRequest? - - var webView: WKWebView? - - var schemeHandler: RDEPUBResourceURLSchemeHandler? - - var configuredPublicationKey: String? - - var currentLoadSignature: String? - - var currentSpineIndex = 0 - - var currentHref = "" - - var currentPageIndex = 0 - - var currentTotalPagesInChapter = 1 - - var viewportSize: CGSize = .zero - - var currentPadding: UIEdgeInsets = .zero - - var currentFontSize: CGFloat = 16 - - var currentLineHeightMultiple: CGFloat = 1.5 - - var currentThemeBackgroundColor: String? - - var currentThemeTextColor: String? - - var targetLocation: RDEPUBLocation? - - var pendingHighlights: [RDEPUBHighlight] = [] - - var fixedSpread: EPUBFixedSpread? - - var isFixedLayout = false - - var pendingProgressionRequest = false - - var fixedLayoutReadyWorkItem: DispatchWorkItem? - - var didRenderCurrentRequest = false - - let debugScope = "ReaderWebView" - - public override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // M-08: Retain cycle note — webView → configuration.userContentController → self (as WKScriptMessageHandler) - // forms a retain cycle. The cycle is broken by calling teardownWebView(), which removes - // the message handlers. teardownWebView() is called in reset() and deinit. IMPORTANT: - // deinit may never trigger if the cycle is not broken first. Every code path that disposes - // of this view MUST call reset() before releasing the last reference. - deinit { - teardownWebView() - } - - public override func layoutSubviews() { - super.layoutSubviews() - webView?.frame = bounds - } - - public func reset() { - delegate = nil - onRendered = nil - onDecorationsResolved = nil - publication = nil - currentRenderRequest = nil - currentSpineIndex = 0 - currentHref = "" - currentPageIndex = 0 - currentTotalPagesInChapter = 1 - viewportSize = .zero - currentPadding = .zero - currentFontSize = 16 - currentLineHeightMultiple = 1.5 - currentThemeBackgroundColor = nil - currentThemeTextColor = nil - targetLocation = nil - pendingHighlights = [] - fixedSpread = nil - isFixedLayout = false - pendingProgressionRequest = false - didRenderCurrentRequest = false - teardownWebView() - } - - public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { - configureWebViewIfNeeded(publication: publication) - guard let webView else { return } - - self.publication = publication - self.currentRenderRequest = request - self.currentSpineIndex = request.primarySpineIndex - self.currentHref = request.primaryHref - self.didRenderCurrentRequest = false - cancelFixedLayoutReadyFallback() - - let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? "" - let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request) - RDEPUBWebViewDebug.log( - debugScope, - message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))" - ) - - switch request { - case .reflowable(let reflowableRequest): - handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView) - case .fixed(let fixedRequest): - handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView) - } - } - - func pageLoadSignature( - publicationKey: String, - request: RDEPUBRenderRequest - ) -> String { - switch request { - case .reflowable(let request): - return reflowableLoadSignature(publicationKey: publicationKey, request: request) - case .fixed(let request): - return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request) - } - } - - func rendered() { - guard !didRenderCurrentRequest else { return } - didRenderCurrentRequest = true - cancelFixedLayoutReadyFallback() - RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self else { return } - self.delegate?.epubWebViewDidFinishRendering(self) - self.onRendered?() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift deleted file mode 100644 index 9486bdb..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift +++ /dev/null @@ -1,106 +0,0 @@ - -import Foundation -import WebKit - -enum RDEPUBWebViewDebug { - - static var isEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool { - return configured - } -#if DEBUG - return true -#else - return false -#endif - }() - - static var isVerboseEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool { - return configured - } - return false - }() - - static var isInspectableEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool { - return configured - } - return false - }() - - static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) { - isInspectableEnabled = inspectableEnabled - isVerboseEnabled = verboseLoggingEnabled - } - - static func webViewID(_ webView: WKWebView?) -> String { - guard let webView else { return "nil-webview" } - return String(ObjectIdentifier(webView).hashValue, radix: 16) - } - - static func log(_ scope: String, message: String) { - guard isEnabled else { return } - print("[RDEpubReaderWK][\(scope)] \(message)") - } - - static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { - guard isEnabled else { return } - let webViewToken = webViewID(webView) - let urlText = summarizedURL(url) - if let error { - log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText) error=\(error.localizedDescription)") - } else { - log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText)") - } - } - - static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) { - guard isEnabled else { return } - log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") - } - - static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { - guard isEnabled else { return } - if isVerboseEnabled { - log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))") - return - } - if let dict = body as? [String: Any] { - let keys = dict.keys.sorted().joined(separator: ",") - var meta = "keys=[\(keys)]" - if let text = dict["text"] as? String { - meta += " textLen=\(text.count)" - } - if let urlString = dict["url"] as? String ?? dict["href"] as? String { - meta += " url=\(summarizedURL(URL(string: urlString)))" - } - log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)") - } else { - log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))") - } - } - - static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) { - guard isEnabled else { return } - let requestText = summarizedURL(requestURL) - let fileText = summarizedURL(fileURL) - if let error { - log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText) error=\(error.localizedDescription)") - } else { - log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText)") - } - } - - static func summarizedURL(_ url: URL?) -> String { - guard let url else { return "nil" } - if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme { - return url.absoluteString - } - let path = url.path - if path.isEmpty { - return url.absoluteString - } - return url.lastPathComponent.isEmpty ? path : url.lastPathComponent - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js deleted file mode 100644 index 521cf11..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js +++ /dev/null @@ -1,67 +0,0 @@ -(function() { - if (window.WeReadApi) { return; } - - function bridge() { - return window.RDEpubReaderBridge || null; - } - - function sharedThemeCSS(theme) { - if (!theme) { return ''; } - var backgroundColor = theme.backgroundColor || 'transparent'; - var textColor = theme.textColor || 'inherit'; - return [ - ':root {', - ' color-scheme: light;', - '}', - 'html, body {', - ' background: ' + backgroundColor + ' !important;', - ' color: ' + textColor + ' !important;', - '}', - 'a {', - ' -webkit-tap-highlight-color: transparent;', - '}' - ].join('\n'); - } - - window.WeReadApi = { - applySharedTheme: function(theme) { - var css = sharedThemeCSS(theme); - if (window.RDInjectedCSS) { - window.RDInjectedCSS.setStyle('ss-reader-theme', css, { - includeFrames: !!(theme && theme.includeFrames) - }); - } - return true; - }, - applyPagination: function(styleText) { - return !!(bridge() && bridge().applyPagination(styleText)); - }, - setPageMetrics: function(pageCount, pageStride) { - return !!(bridge() && bridge().setPageMetrics(pageCount, pageStride)); - }, - scrollToPage: function(pageIndex) { - return !!(bridge() && bridge().scrollToPage(pageIndex)); - }, - scrollToLocation: function(location, fallbackPageIndex) { - return !!(bridge() && bridge().scrollToLocation(location, fallbackPageIndex)); - }, - setHighlights: function(items) { - return !!(bridge() && bridge().setHighlights(items)); - }, - clearHighlights: function() { - return !!(bridge() && bridge().clearHighlights()); - }, - setSearchPresentation: function(payload) { - return !!(bridge() && bridge().setSearchPresentation(payload)); - }, - resolveDecorations: function() { - return bridge() ? bridge().resolveDecorations() : { highlights: [], search: [] }; - }, - reportProgression: function(fragment) { - return !!(bridge() && bridge().reportProgression(fragment)); - }, - selectionPayload: function() { - return bridge() ? bridge().selectionPayload() : null; - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js deleted file mode 100644 index 9517c26..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js +++ /dev/null @@ -1,45 +0,0 @@ -(function() { - if (window.RDInjectedCSS) { return; } - - function applyStyle(doc, identifier, cssText) { - if (!doc || !doc.head) { return; } - var style = doc.getElementById(identifier); - if (!style) { - style = doc.createElement('style'); - style.id = identifier; - doc.head.appendChild(style); - } - style.textContent = cssText || ''; - } - - function walkDocuments(rootDoc, includeFrames, visitor) { - if (!rootDoc) { return; } - visitor(rootDoc); - if (!includeFrames || !rootDoc.querySelectorAll) { return; } - Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) { - try { - if (!frame.contentDocument) { return; } - walkDocuments(frame.contentDocument, includeFrames, visitor); - } catch (error) { - } - }); - } - - window.RDInjectedCSS = { - setStyle: function(identifier, cssText, options) { - var includeFrames = !!(options && options.includeFrames); - walkDocuments(document, includeFrames, function(doc) { - applyStyle(doc, identifier, cssText); - }); - }, - removeStyle: function(identifier, options) { - var includeFrames = !!(options && options.includeFrames); - walkDocuments(document, includeFrames, function(doc) { - var style = doc.getElementById(identifier); - if (style && style.parentNode) { - style.parentNode.removeChild(style); - } - }); - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js deleted file mode 100644 index f59c67e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js +++ /dev/null @@ -1,550 +0,0 @@ - (function() { - if (window.RDEpubReaderBridge) { return; } - window.addEventListener('error', function(event) { - try { - var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error'; - window.webkit.messageHandlers.{{JAVASCRIPT_ERROR_MESSAGE}}.postMessage(message); - } catch (error) { - } - }); - function serializeRange(range) { - if (window.rangy && typeof window.rangy.serializeRange === 'function') { - return window.rangy.serializeRange(range, range && range.startContainer && range.startContainer.ownerDocument); - } - return null; - } - function rangeFromInfo(rangeInfo) { - if (window.rangy && typeof window.rangy.deserializeRange === 'function') { - return window.rangy.deserializeRange(rangeInfo, document); - } - return null; - } - var visiblePageIndex = 0; - var configuredPageCount = 1; - var configuredPageStride = 1; - var relayoutTimer = null; - var fixedLayoutDocument = false; - function isFixedLayoutDocument() { - if (fixedLayoutDocument) { return true; } - fixedLayoutDocument = !!document.getElementById('ss-fixed-root') || !!document.querySelector('.ss-fixed-page'); - return fixedLayoutDocument; - } - function ensurePaginationStructure() { - if (isFixedLayoutDocument()) { - return null; - } - var viewport = document.getElementById('ss-reader-viewport'); - var content = document.getElementById('ss-reader-content'); - if (viewport && content) { - return content; - } - - viewport = document.createElement('div'); - viewport.id = 'ss-reader-viewport'; - content = document.createElement('div'); - content.id = 'ss-reader-content'; - - while (document.body.firstChild) { - content.appendChild(document.body.firstChild); - } - - viewport.appendChild(content); - document.body.appendChild(viewport); - return content; - } - function paginationContent() { - if (isFixedLayoutDocument()) { - return null; - } - return ensurePaginationStructure(); - } - function effectivePageStride() { - return Math.max(1, configuredPageStride || 1); - } - function measuredPageCount() { - if (isFixedLayoutDocument()) { - return 1; - } - var content = paginationContent(); - var scrollingElement = document.scrollingElement || document.documentElement || document.body; - var scrollWidth = Math.max( - content ? content.scrollWidth : 0, - content ? content.offsetWidth : 0, - scrollingElement ? scrollingElement.scrollWidth : 0, - document.documentElement ? document.documentElement.scrollWidth : 0, - document.body ? document.body.scrollWidth : 0, - effectivePageStride() - ); - return Math.max(1, Math.ceil(scrollWidth / effectivePageStride())); - } - function totalPageCount() { - return Math.max(configuredPageCount, measuredPageCount()); - } - function currentPageOffset() { - return Math.round(visiblePageIndex * effectivePageStride()); - } - function reportProgression(fragment) { - var pageCount = totalPageCount(); - var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); - var lastProgression = progression; - window.webkit.messageHandlers.{{PROGRESSION_CHANGED_MESSAGE}}.postMessage({ - progression: progression, - lastProgression: lastProgression, - fragment: fragment || null - }); - } - function setVisiblePageIndex(pageIndex) { - if (isFixedLayoutDocument()) { - visiblePageIndex = 0; - return; - } - var pageCount = totalPageCount(); - var maxPageIndex = Math.max(0, pageCount - 1); - var safePageIndex = Math.max(0, Math.min(maxPageIndex, Math.round(pageIndex || 0))); - visiblePageIndex = safePageIndex; - var content = paginationContent(); - if (content) { - content.style.transform = 'translate3d(' + (-currentPageOffset()) + 'px, 0, 0)'; - } - } - function scheduleRelayout() { - if (isFixedLayoutDocument()) { return; } - clearTimeout(relayoutTimer); - relayoutTimer = setTimeout(function() { - setVisiblePageIndex(visiblePageIndex); - reportProgression(null); - }, 60); - } - function pageIndexForElement(target) { - if (isFixedLayoutDocument()) { return 0; } - if (!target) { return 0; } - var rect = target.getBoundingClientRect(); - var absoluteLeft = Math.max(0, rect.left + currentPageOffset()); - return Math.max(0, Math.floor(absoluteLeft / effectivePageStride())); - } - function fragmentTarget(fragment) { - if (!fragment) { return null; } - var contexts = documentContexts(document, 0, 0); - for (var index = 0; index < contexts.length; index += 1) { - var context = contexts[index]; - var target = context.doc.getElementById(fragment) - || context.doc.querySelector('[name="' + fragment.replace(/"/g, '\\"') + '"]'); - if (target) { - return { - context: context, - target: target - }; - } - } - return null; - } - var storedHighlights = []; - var storedSearchPresentation = null; - function normalizeHref(rawHref) { - if (!rawHref) { return ''; } - var value = String(rawHref); - value = value.replace(/^.*?:\/\/[^/]+\//, ''); - value = value.split('#')[0]; - value = value.replace(/^\/+/, ''); - try { - value = decodeURIComponent(value); - } catch (error) { - } - return value; - } - function clearHighlights() { - storedHighlights = []; - } - function setHighlights(items) { - storedHighlights = Array.isArray(items) ? items.slice() : []; - } - function setSearchPresentation(payload) { - storedSearchPresentation = payload || null; - } - function textNodes(doc) { - if (!doc || !doc.body) { return []; } - var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { - acceptNode: function(node) { - if (!node || !node.nodeValue || !node.nodeValue.trim()) { - return NodeFilter.FILTER_REJECT; - } - var parent = node.parentNode; - if (!parent || !parent.nodeName) { - return NodeFilter.FILTER_REJECT; - } - var name = parent.nodeName.toLowerCase(); - if (['script', 'style', 'noscript'].indexOf(name) >= 0) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - }); - var nodes = []; - while (walker.nextNode()) { - nodes.push(walker.currentNode); - } - return nodes; - } - function documentContexts(rootDoc, baseOffsetX, baseOffsetY) { - var items = []; - if (!rootDoc) { return items; } - var offsetX = baseOffsetX || 0; - var offsetY = baseOffsetY || 0; - items.push({ - doc: rootDoc, - href: normalizeHref(rootDoc.location && rootDoc.location.href), - offsetX: offsetX, - offsetY: offsetY - }); - var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; - frames.forEach(function(frame) { - try { - if (!frame.contentDocument) { return; } - var frameRect = frame.getBoundingClientRect(); - documentContexts( - frame.contentDocument, - offsetX + frameRect.left, - offsetY + frameRect.top - ).forEach(function(item) { - items.push(item); - }); - } catch (error) { - } - }); - return items; - } - var hookedDocuments = new WeakSet(); - var hookedFrames = new WeakSet(); - function selectionForDocument(doc) { - if (!doc) { return null; } - try { - var view = doc.defaultView || window; - return view.getSelection ? view.getSelection() : null; - } catch (error) { - return null; - } - } - function currentSelectionContext() { - var contexts = documentContexts(document, 0, 0); - for (var index = 0; index < contexts.length; index += 1) { - var context = contexts[index]; - var selection = selectionForDocument(context.doc); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - continue; - } - var text = selection.toString ? selection.toString() : ''; - if (!text || !text.trim()) { - continue; - } - return { - context: context, - selection: selection - }; - } - return null; - } - function rectPayloadForRange(range, offsetX, offsetY) { - if (!range || range.collapsed) { return []; } - var rects = []; - Array.prototype.forEach.call(range.getClientRects(), function(rect) { - if (!rect || rect.width < 0.5 || rect.height < 0.5) { return; } - rects.push({ - x: rect.left + (offsetX || 0), - y: rect.top + (offsetY || 0), - width: rect.width, - height: rect.height - }); - }); - return rects; - } - function collectHighlightDecorations() { - return storedHighlights.map(function(item, index) { - if (!item || !item.rangeInfo) { return null; } - var range = rangeFromInfo(item.rangeInfo); - var rects = rectPayloadForRange(range, 0, 0); - if (!rects.length) { return null; } - return { - key: item.id || ('highlight-' + index), - kind: item.style === 'underline' ? 'underline' : 'highlight', - color: item.color || '#F8E16C', - rangeInfo: item.rangeInfo, - rects: rects - }; - }).filter(Boolean); - } - function collectSearchDecorations() { - if (!storedSearchPresentation || !storedSearchPresentation.keyword) { - return []; - } - - var resources = new Map(); - if (Array.isArray(storedSearchPresentation.resources)) { - storedSearchPresentation.resources.forEach(function(resource) { - resources.set(normalizeHref(resource.href), resource); - }); - } - - var keyword = String(storedSearchPresentation.keyword); - var lowerKeyword = keyword.toLowerCase(); - var decorations = []; - - documentContexts(document, 0, 0).forEach(function(context) { - var resourceState = resources.get(context.href); - if (!resourceState || resourceState.matchCount <= 0) { - return; - } - - var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null; - var currentLocalIndex = 0; - var activeRect = null; - var contextStartIndex = decorations.length; - - textNodes(context.doc).forEach(function(node) { - var source = node.nodeValue || ''; - var lowerSource = source.toLowerCase(); - var cursor = 0; - - while (cursor < source.length) { - var foundIndex = lowerSource.indexOf(lowerKeyword, cursor); - if (foundIndex < 0) { - break; - } - - var range = context.doc.createRange(); - range.setStart(node, foundIndex); - range.setEnd(node, foundIndex + keyword.length); - var rects = rectPayloadForRange(range, context.offsetX, context.offsetY); - if (rects.length) { - var isActive = currentLocalIndex === activeIndex; - decorations.push({ - key: context.href + ':' + currentLocalIndex, - kind: isActive ? 'activeSearch' : 'search', - rects: rects - }); - if (isActive) { - activeRect = rects[0]; - } - } - - currentLocalIndex += 1; - cursor = foundIndex + keyword.length; - } - }); - - if (activeRect && !isFixedLayoutDocument()) { - var previousPageIndex = visiblePageIndex; - var activePageIndex = Math.max(0, Math.floor(Math.max(0, activeRect.x + currentPageOffset()) / effectivePageStride())); - setVisiblePageIndex(activePageIndex); - var deltaX = (activePageIndex - previousPageIndex) * effectivePageStride(); - if (deltaX !== 0) { - for (var index = contextStartIndex; index < decorations.length; index += 1) { - decorations[index].rects = decorations[index].rects.map(function(rect) { - return { - x: rect.x - deltaX, - y: rect.y, - width: rect.width, - height: rect.height - }; - }); - } - } - } - }); - return decorations; - } - function resolveDecorations() { - return { - highlights: collectHighlightDecorations(), - search: collectSearchDecorations() - }; - } - function selectionPayload() { - var payloadContext = currentSelectionContext(); - if (!payloadContext) { - return null; - } - var range = payloadContext.selection.getRangeAt(0); - var text = payloadContext.selection.toString(); - if (!text || !text.trim()) { return null; } - var pageCount = totalPageCount(); - var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); - var lastProgression = progression; - return { - href: payloadContext.context.href || null, - text: text, - rangeInfo: serializeRange(range), - progression: progression, - lastProgression: lastProgression - }; - } - var selectionTimer = null; - function notifySelectionChange() { - clearTimeout(selectionTimer); - selectionTimer = setTimeout(function() { - var payload = selectionPayload(); - window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload); - }, 120); - } - function handleDocumentClick(event) { - // Check for image tap. Footnote images always show the note popup; regular - // images inside links are left to the link handler below. - var img = event.target; - while (img && img.tagName !== 'IMG') { - img = img.parentElement; - } - if (img && img.tagName === 'IMG') { - var src = img.getAttribute('src'); - var classes = (img.getAttribute('class') || '').toLowerCase(); - var alt = (img.getAttribute('alt') || '').trim(); - var isFootnote = classes.indexOf('qqreader-footnote') >= 0 || alt.length > 0; - if (isFootnote) { - event.preventDefault(); - event.stopPropagation(); - var footnoteRect = img.getBoundingClientRect(); - window.webkit.messageHandlers.{{FOOTNOTE_DID_TAP_MESSAGE}}.postMessage({ - alt: alt, - rect: { x: footnoteRect.x, y: footnoteRect.y, width: footnoteRect.width, height: footnoteRect.height } - }); - return; - } - - // If a regular image is inside a link, let the link handler deal with it. - var linkParent = img.closest ? img.closest('a[href]') : null; - if (!linkParent) { - if (src) { - event.preventDefault(); - event.stopPropagation(); - var rect = img.getBoundingClientRect(); - window.webkit.messageHandlers.{{IMAGE_DID_TAP_MESSAGE}}.postMessage({ - src: src, - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - }); - return; - } - } - } - var anchor = event.target.closest ? event.target.closest('a[href]') : null; - if (!anchor) { return; } - var href = anchor.getAttribute('href'); - if (!href) { return; } - if (/^(https?:|mailto:|tel:)/i.test(href)) { - event.preventDefault(); - window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href }); - return; - } - event.preventDefault(); - window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href }); - } - function installDocumentHooks(doc) { - if (!doc || hookedDocuments.has(doc)) { return; } - hookedDocuments.add(doc); - doc.addEventListener('selectionchange', notifySelectionChange); - doc.addEventListener('click', handleDocumentClick, true); - } - function installFrameHooks(rootDoc) { - if (!rootDoc || !rootDoc.querySelectorAll) { return; } - Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) { - if (hookedFrames.has(frame)) { return; } - hookedFrames.add(frame); - var hookFrameDocument = function() { - try { - if (!frame.contentDocument) { return; } - installDocumentHooks(frame.contentDocument); - installFrameHooks(frame.contentDocument); - } catch (error) { - } - }; - frame.addEventListener('load', function() { - hookFrameDocument(); - scheduleRelayout(); - }, { passive: true }); - hookFrameDocument(); - }); - } - installDocumentHooks(document); - installFrameHooks(document); - window.addEventListener('resize', function() { - if (isFixedLayoutDocument()) { return; } - setVisiblePageIndex(visiblePageIndex); - reportProgression(null); - }, { passive: true }); - window.addEventListener('load', scheduleRelayout, { passive: true }); - if (!isFixedLayoutDocument() && document.fonts && document.fonts.ready) { - document.fonts.ready.then(scheduleRelayout).catch(function() {}); - } - if (!isFixedLayoutDocument()) { - Array.prototype.forEach.call(document.querySelectorAll('img, iframe, video'), function(node) { - node.addEventListener('load', scheduleRelayout, { passive: true }); - node.addEventListener('error', scheduleRelayout, { passive: true }); - }); - } - window.RDEpubReaderBridge = { - applyPagination: function(styleText) { - if (isFixedLayoutDocument()) { return; } - if (window.RDInjectedCSS) { - window.RDInjectedCSS.setStyle('ss-reader-pagination', styleText, { includeFrames: false }); - } else { - var style = document.getElementById('ss-reader-pagination'); - if (!style) { - style = document.createElement('style'); - style.id = 'ss-reader-pagination'; - document.head.appendChild(style); - } - style.textContent = styleText; - } - ensurePaginationStructure(); - installFrameHooks(document); - }, - setPageMetrics: function(pageCount, pageStride) { - if (isFixedLayoutDocument()) { return; } - configuredPageCount = Math.max(1, Math.round(pageCount || 1)); - configuredPageStride = Math.max(1, Number(pageStride) || configuredPageStride || 1); - setVisiblePageIndex(visiblePageIndex); - scheduleRelayout(); - }, - scrollToPage: function(pageIndex) { - if (isFixedLayoutDocument()) { return; } - setVisiblePageIndex(pageIndex); - reportProgression(null); - }, - scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) { - if (targetRangeInfo) { - var highlightRange = rangeFromInfo(targetRangeInfo); - var highlightRects = rectPayloadForRange(highlightRange, 0, 0); - if (highlightRects.length) { - var highlightRect = highlightRects[0]; - var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset()); - setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride()))); - reportProgression(location && location.fragment ? location.fragment : null); - return; - } - } - if (location && location.fragment) { - var resolvedTarget = fragmentTarget(location.fragment); - if (resolvedTarget) { - if (isFixedLayoutDocument()) { - reportProgression(location.fragment); - return; - } - var rect = resolvedTarget.target.getBoundingClientRect(); - var absoluteLeft = Math.max(0, rect.left + resolvedTarget.context.offsetX + currentPageOffset()); - setVisiblePageIndex(Math.max(0, Math.floor(absoluteLeft / effectivePageStride()))); - reportProgression(location.fragment); - return; - } - } - var pageCount = totalPageCount(); - var progression = location && typeof location.progression === 'number' ? location.progression : 0; - var derivedPageIndex = progression > 0 && pageCount > 1 - ? Math.round(progression * Math.max(pageCount - 1, 0)) - : (fallbackPageIndex || 0); - this.scrollToPage(derivedPageIndex); - }, - setHighlights: setHighlights, - clearHighlights: clearHighlights, - setSearchPresentation: setSearchPresentation, - resolveDecorations: resolveDecorations, - reportProgression: reportProgression, - selectionPayload: selectionPayload - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html deleted file mode 100644 index ddcc026..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - -代码块,注意必须写font-weight使字体生效 */
-pre {
- font-family: "Menlo";
- font-weight: normal;
- line-height: 1.5em;
-}
-
-/*版权信息*/
-.copyRightTitle {
- color: black;
- font-size: 1.5em;
- font-family: "Source Han Serif CN";
- font-weight: normal;
-}
-
-/*图片说明文字*/
-.eepub-single-image-title {
- font-size: 0.75em;
- text-align: center;
- line-height: 1.4em;
- color: rgba(0, 0, 0, 0.9);
- margin: 0.2em 0.4em 1em 0.4em;
- font-weight: normal;
-}
-
-/*标题*/
-.firstTitle, h1.firstTitle {
- font-size: 1.5em;
- font-weight: bold;
- line-height: 1.25em;
-}
-.secondTitle, h2.secondTitle {
- font-size: 1.4em;
- font-weight: bold;
- line-height: 1.35em;
-}
-.thirdTitle, h3.thirdTitle {
- font-size: 1.3em;
- font-weight: bold;
- line-height: 1.5em;
-}
-.fourthTitle, h4.fourthTitle {
- font-size: 1.2em;
- font-weight: bold;
- line-height: 1.65em;
-}
-.fifthTitle, h5.fifthTitle {
- font-size: 1.1em;
- font-weight: bold;
- line-height: 1.85em;
-}
-.sixthTitle, h6.sixthTitle {
- font-size: 1em;
- font-weight: bold;
- line-height: 2em;
-}
-
-/*首字加大*/
-/*浮动元素有默认的margin,所以这里会修复一下*/
-.ftext {
- float: left;
- margin: 0em;
- font-size: 2.38em;
- font-weight: normal;
-}
-
-/*引用内容*/
-.conQuot {
- font-weight: normal;
- margin: 0em 0em 0.2em 0em;
-}
-
-/*标题下来可能会有一行subHead*/
-.subHead{
- text-indent: 2em;
-}
-
-pre, pre span, pre code {
- font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
- font-size:.7em!important;
-}
-
-.bodyPic {
- wr-vertical-center-style: 2;
-}
-
-.qrbodyPic {
- page-break-inside: avoid;
- wr-vertical-center-style: 2;
-}
-
-/* 注标图 */
-.qqreader-footnote {
- width: 1em;
-}
-
-/* 私有垂直居中类 */
-.wr-vertical-center {
- wr-vertical-center-style: 1 !important;
-}
-
-/* 翻译 */
-.wr-translation {
- line-height: 1.7em !important;
-}
-
-/* 章节结尾工具 */
-.book-chapter-tool {
- display: block;
- height: 78px;
- width: 100%;
- margin-top: 24px;
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css
deleted file mode 100644
index 7c4909c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css
+++ /dev/null
@@ -1,171 +0,0 @@
-/* 代码块,注意必须写font-weight使字体生效 */
-pre {
- font-family: "Menlo";
- font-weight: normal;
- line-height: 1.5em;
-}
-
-/*版权信息*/
-.copyRightTitle {
- color: black;
- font-size: 1.5em;
- font-family: "Source Han Serif CN";
- font-weight: normal;
-}
-
-/*图片说明文字*/
-.eepub-single-image-title {
- font-size: 0.75em;
- text-align: center;
- line-height: 1.4em;
- color: rgba(0, 0, 0, 0.9);
- margin: 0.2em 0.4em 1em 0.4em;
- font-family: "FZFSJW--GB1-0";
- font-weight: normal;
-}
-
-/*标题*/
-.firstTitle, h1.firstTitle {
- font-size: 1.5em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 1.25em;
-}
-.secondTitle, h2.secondTitle {
- font-size: 1.4em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 1.35em;
-}
-.thirdTitle, h3.thirdTitle {
- font-size: 1.3em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 1.5em;
-}
-.fourthTitle, h4.fourthTitle {
- font-size: 1.2em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 1.65em;
-}
-.fifthTitle, h5.fifthTitle {
- font-size: 1.1em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 1.85em;
-}
-.sixthTitle, h6.sixthTitle {
- font-size: 1em;
- font-family: "Source Han Serif CN";
- font-weight: bold;
- line-height: 2em;
-}
-
-/*首字加大*/
-/*浮动元素有默认的margin,所以这里会修复一下*/
-.ftext {
- float: left;
- margin: 0em;
- font-size: 2.38em;
- font-weight: normal;
-}
-
-/*引用内容*/
-.conQuot {
- font-family: "FZFSJW--GB1-0";
- font-weight: normal;
- margin: 0em 0em 0.2em 0em;
-}
-
-/*标题下来可能会有一行subHead*/
-.subHead{
- text-indent: 2em;
-}
-
-pre, pre span, pre code {
- font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
- font-size:.7em!important;
-}
-
-.bodyPic {
- wr-vertical-center-style: 2;
-}
-
-.qrbodyPic {
- page-break-inside: avoid;
- wr-vertical-center-style: 2;
-}
-
-/* 注标图 */
-.qqreader-footnote {
- width: 1em;
-}
-
-/* 翻译 */
-.wr-translation {
- line-height: 1.7em !important;
-}
-
-/*-------------------- 小文章相关 ----------------------*/
-
-/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
-.weread-page-relate {
- weread-page-relate:true;
-}
-
-/*epub-custom-rule-29 小文章打赏的样式*/
-.chapter-reward {
- display: block;
- height: 120px;
- width: 100%;
- margin-top: 24px;
-}
-
-/*epub-custom-rule-7 小文章工具栏标签的样式*/
-.chapter-tool {
- display: block;
- height: 140px;
- width: 100%;
- margin-top: 24px;
-}
-
-/* 章节结尾工具 */
-.book-chapter-tool {
- display: block;
- height: 78px;
- width: 100%;
- margin-top: 24px;
-}
-
-/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
-
-/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
-.re_bookItem{
- display: block;
- margin: 18px 0 18px 0;
-}
-
-p {
- text-align:justify;
-}
-
-img[data-image-size="large"] {
- width: 100%;
-}
-
-/* 文集公众号文章不支持控件的样式 */
-.unsupported_iframe {
- border-radius: 4px;
- border: 1px solid #ffcdc0;
- background: #fee;
- padding: 12px 0px;
- color: #ff8d8d;
- text-align: center;
- font-size: 14px;
-}
-
-/* 私有垂直居中类 */
-.wr-vertical-center {
- wr-vertical-center-style: 1 !important;
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift
deleted file mode 100644
index e169d71..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift
+++ /dev/null
@@ -1,63 +0,0 @@
-import Foundation
-
-struct RDEPUBBuildDiagnosticsReporter {
-
- func phase7SemanticSummary(
- title: String?,
- diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
- ) -> String? {
-
- guard !diagnostics.isEmpty else { return nil }
-
- let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
- let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
- let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
-
- let note = diagnostics
- .flatMap(\.sampleNotes)
- .first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
-
- var parts = [
- title,
- "章节 \(diagnostics.count)",
- blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
- semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
- attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
- ].compactMap { $0 }
-
- if let note {
- parts.append(note)
- }
- return parts.joined(separator: " · ")
- }
-
- func chapterDiagnostic(
- href: String,
- title: String,
- pages: [RDEPUBTextPage]
- ) -> RDEPUBTextChapterPaginationDiagnostic {
- RDEPUBTextChapterPaginationDiagnostic(
- href: href,
- title: title,
- pageCount: pages.count,
- breakReasons: pages.map(\.metadata.breakReason),
-
- attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
-
- blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
- blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
- semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
- attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
-
- sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
- )
- }
-
- private func uniqueValues(from values: [T]) -> [T] {
- values.reduce(into: [T]()) { result, value in
- if !result.contains(value) {
- result.append(value)
- }
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift
deleted file mode 100644
index 6a2b19f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift
+++ /dev/null
@@ -1,197 +0,0 @@
-import Foundation
-
-struct RDEPUBChapterTailNormalizer {
-
- func normalize(
- _ frames: [RDEPUBTextLayoutFrame],
- content: NSAttributedString,
- href: String
- ) -> [RDEPUBTextLayoutFrame] {
-
- guard frames.count > 1 else { return frames }
-
- var normalized = frames
-
- var compacted: [RDEPUBTextLayoutFrame] = []
- compacted.reserveCapacity(normalized.count)
- for frame in normalized {
- if shouldDropWhitespaceOnlyFrame(frame, in: content) {
- let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
-
- if var previous = compacted.popLast() {
- previous.diagnostics.append(note)
- compacted.append(previous)
- } else {
- }
- continue
- }
- compacted.append(frame)
- }
- normalized = compacted
-
- while let lastFrame = normalized.last,
- shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
- normalized.removeLast()
- let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
- if var previousFrame = normalized.popLast() {
- previousFrame.diagnostics.append(note)
- normalized.append(previousFrame)
- } else {
- }
- }
-
- guard normalized.count > 1,
- let lastFrame = normalized.last,
- let previousFrame = normalized.dropLast().last,
- shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
- return normalized
- }
-
- let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
- normalized.removeLast(2)
- normalized.append(mergedFrame)
- return normalized
- }
-
- private func shouldDropWhitespaceOnlyFrame(
- _ frame: RDEPUBTextLayoutFrame,
- in content: NSAttributedString
- ) -> Bool {
- guard frame.contentRange.length > 0,
- attachmentCount(in: content, range: frame.contentRange) == 0 else {
- return false
- }
- return visibleCharacterCount(in: content, range: frame.contentRange) == 0
- }
-
- private func shouldMergeShortTrailingFrame(
- _ trailingFrame: RDEPUBTextLayoutFrame,
- previousFrame: RDEPUBTextLayoutFrame,
- in content: NSAttributedString
- ) -> Bool {
-
- guard trailingFrame.contentRange.length > 0,
- NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
- return false
- }
-
- let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
- let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
-
- guard visibleCount <= 2,
- trailingFrame.contentRange.length <= 2,
- visibleCount > 0 || trailingAttachmentCount > 0 else {
- return false
- }
-
- guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
- return false
- }
-
- let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
- return previousVisibleCount >= max(visibleCount * 8, 12)
- }
-
- private func frameHasAvoidPageBreakInside(
- _ frame: RDEPUBTextLayoutFrame,
- in content: NSAttributedString
- ) -> Bool {
- if frame.semanticHints.contains(.avoidPageBreakInside) {
- return true
- }
-
- guard content.length > 0,
- let safeRange = clampedRange(frame.contentRange, in: content),
- safeRange.length > 0 else {
- return false
- }
-
- var found = false
- content.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, stop in
- guard let rawValue = value as? String else { return }
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- if hints.contains(.avoidPageBreakInside) {
- found = true
- stop.pointee = true
- }
- }
- return found
- }
-
- private func mergeTrailingFrame(
- _ previousFrame: RDEPUBTextLayoutFrame,
- with trailingFrame: RDEPUBTextLayoutFrame
- ) -> RDEPUBTextLayoutFrame {
-
- let mergedRange = NSRange(
- location: previousFrame.contentRange.location,
- length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
- )
-
- return RDEPUBTextLayoutFrame(
- contentRange: mergedRange,
- breakReason: trailingFrame.breakReason,
- blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
- attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
- attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
- blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
- semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
- attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
- trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
-
- diagnostics: previousFrame.diagnostics
- + trailingFrame.diagnostics
- + ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
- )
- }
-
- private func visibleCharacterCount(
- in content: NSAttributedString,
- range: NSRange
- ) -> Int {
- guard range.length > 0 else { return 0 }
- let string = content.attributedSubstring(from: range).string
-
- let filteredScalars = string.unicodeScalars.filter { scalar in
- !CharacterSet.whitespacesAndNewlines.contains(scalar)
- && !CharacterSet.controlCharacters.contains(scalar)
- }
- return filteredScalars.count
- }
-
- private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
- guard content.length > 0, range.length > 0 else { return 0 }
- var count = 0
- content.enumerateAttribute(.attachment, in: range) { value, _, _ in
- if value != nil {
- count += 1
- }
- }
- return count
- }
-
- private func clampedRange(_ range: NSRange, in content: NSAttributedString) -> NSRange? {
- guard range.location >= 0, range.length >= 0, range.location < content.length else {
- return nil
- }
- return NSRange(location: range.location, length: min(range.length, content.length - range.location))
- }
-
- private func uniqueValues(from values: [T]) -> [T] {
- values.reduce(into: [T]()) { result, value in
- if !result.contains(value) {
- result.append(value)
- }
- }
- }
-
- private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
- ranges.reduce(into: [NSRange]()) { result, value in
- if !result.contains(value) {
- result.append(value)
- }
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift
deleted file mode 100644
index d2a3cf8..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import UIKit
-
-struct RDEPUBPaginationCacheCoordinator {
-
- private let cache: RDEPUBTextBookCache?
-
- private let layoutConfig: RDEPUBTextLayoutConfig
-
- init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
- self.cache = cache
- self.layoutConfig = layoutConfig
- }
-
- func cacheKey(
- bookID: String,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle
- ) -> String? {
- guard let cache else { return nil }
- return cache.cacheKey(
- bookID: bookID,
- fontSize: style.font.pointSize,
- lineHeightMultiple: style.lineSpacing,
- contentInsets: layoutConfig.edgeInsets,
- pageSize: pageSize,
- layoutConfigSignature: "\(layoutConfig.cacheSignature)|font=\(style.font.fontName)"
- )
- }
-
- func load(key: String?) -> [String: RDEPUBTextChapterPaginationCache]? {
- key.flatMap { cache?.load(key: $0) }
- }
-
- func save(chapters: [RDEPUBTextChapter], key: String?) {
- guard let key else { return }
-
- let paginationCache = chapters.map { chapter in
- RDEPUBTextChapterPaginationCache(
- href: chapter.href,
- pageRanges: chapter.pages.map(\.contentRange),
- breakReasons: chapter.pages.map(\.metadata.breakReason),
-
- semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
- )
- }
- cache?.save(paginationCache, key: key)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift
deleted file mode 100644
index a57f381..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift
+++ /dev/null
@@ -1,420 +0,0 @@
-import UIKit
-
-public final class RDEPUBTextBookBuilder {
- private let renderer: RDEPUBTextRenderer
- private let cache: RDEPUBTextBookCache?
- private let layoutConfig: RDEPUBTextLayoutConfig
- private let sampler: RDEPUBTextPerformanceSampler
- private let renderPipeline: RDEPUBChapterRenderPipeline
- private let paginationPipeline: RDEPUBChapterPaginationPipeline
- private let tailNormalizer: RDEPUBChapterTailNormalizer
- private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
- private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
-
- public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
-
- public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
-
- public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
-
- public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
-
- public init(
- renderer: RDEPUBTextRenderer,
- cache: RDEPUBTextBookCache? = nil,
- layoutConfig: RDEPUBTextLayoutConfig = .default
- ) {
- self.renderer = renderer
- self.cache = cache
- self.layoutConfig = layoutConfig
- self.sampler = RDEPUBTextPerformanceSampler()
- self.renderPipeline = RDEPUBChapterRenderPipeline(renderer: renderer)
- self.paginationPipeline = RDEPUBChapterPaginationPipeline()
- self.tailNormalizer = RDEPUBChapterTailNormalizer()
- self.cacheCoordinator = RDEPUBPaginationCacheCoordinator(cache: cache, layoutConfig: layoutConfig)
- self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
- }
-
- public convenience init() {
- self.init(renderer: RDEPUBDTCoreTextRenderer())
- }
-
- private var isPaginationDebugEnabled: Bool {
- ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
- }
-
- public func phase7SemanticSummary(title: String? = nil) -> String? {
- diagnosticsReporter.phase7SemanticSummary(
- title: title,
- diagnostics: lastBuildPaginationDiagnostics
- )
- }
-
- public func build(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBTextBook {
- if isPaginationDebugEnabled {
- print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
- }
- var chapters: [RDEPUBTextChapter] = []
- var flatPages: [RDEPUBTextPage] = []
- lastBuildResourceDiagnostics = []
- lastBuildPaginationDiagnostics = []
- lastBuildPerformanceSamples = []
- lastBuildCacheStats = (0, 0)
- sampler.reset()
-
- let buildStart = CFAbsoluteTimeGetCurrent()
-
- let bookID = publication.metadata.identifier ?? publication.metadata.title
- let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
- let cachedPagination = cacheCoordinator.load(key: cacheKey)
-
- for (spineIndex, item) in publication.spine.enumerated() where item.linear {
- guard let result = try buildChapter(
- parser: parser,
- publication: publication,
- spineIndex: spineIndex,
- pageSize: pageSize,
- style: style,
- chapterIndex: chapters.count,
- absolutePageStartIndex: flatPages.count,
- cachedPagination: cachedPagination
- ) else { continue }
-
- if isPaginationDebugEnabled,
- item.href.contains("Chapter_3.xhtml") {
- let pages = result.chapter.pages
- print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
- for page in pages {
- let preview = debugPreview(for: page.content, limit: 36)
- print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
- for note in page.metadata.diagnostics.prefix(4) {
- print("[PaginationDebug] note=\(note)")
- }
- }
- }
-
- chapters.append(result.chapter)
- flatPages.append(contentsOf: result.chapter.pages)
- lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
- lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
- sampler.record(result.performanceSample)
- if result.cacheHit {
- lastBuildCacheStats.hits += 1
- } else {
- lastBuildCacheStats.misses += 1
- }
- }
-
- let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
-
- sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
-
- cacheCoordinator.save(chapters: chapters, key: cacheKey)
-
- #if DEBUG
- print(sampler.summary())
- #endif
- lastBuildPerformanceSamples = sampler.samples
-
- return book
- }
-
- public func buildChapter(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- spineIndex: Int,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle,
- chapterIndex: Int = 0,
- absolutePageStartIndex: Int = 0
- ) throws -> RDEPUBTextChapterBuildResult? {
- let bookID = publication.metadata.identifier ?? publication.metadata.title
- let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
- let cachedPagination = cacheCoordinator.load(key: cacheKey)
- return try buildChapter(
- parser: parser,
- publication: publication,
- spineIndex: spineIndex,
- pageSize: pageSize,
- style: style,
- chapterIndex: chapterIndex,
- absolutePageStartIndex: absolutePageStartIndex,
- cachedPagination: cachedPagination
- )
- }
-
- private func buildChapter(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- spineIndex: Int,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle,
- chapterIndex: Int,
- absolutePageStartIndex: Int,
- cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
- ) throws -> RDEPUBTextChapterBuildResult? {
- guard publication.spine.indices.contains(spineIndex) else { return nil }
- let item = publication.spine[spineIndex]
- guard item.linear,
- item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
- let rawHTML = parser.htmlString(forRelativePath: item.href) else {
- return nil
- }
-
- let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
- let request = RDEPUBTextTypesetterPipeline().makeRequest(
- from: RDEPUBTypesettingInput(
- href: item.href,
- spineIndex: spineIndex,
- title: chapterTitle,
- rawHTML: rawHTML,
- baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
- style: style,
- resourceResolver: publication.resourceResolver,
- contentLanguageCode: publication.metadata.language,
- pageSize: pageSize,
- layoutConfig: layoutConfig
- )
- ).request
-
- let renderStart = CFAbsoluteTimeGetCurrent()
- let rendered = try renderPipeline.render(request)
- let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
-
- let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
- if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
- if item.href.lowercased().contains("cover") {
- }
- return nil
- }
-
- let content = NSMutableAttributedString(attributedString: rendered.attributedString)
- let paginateStart = CFAbsoluteTimeGetCurrent()
- let layoutFrames: [RDEPUBTextLayoutFrame]
- let isCacheHit: Bool
-
- if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
- layoutFrames = [
- RDEPUBTextLayoutFrame(
- contentRange: NSRange(location: 0, length: content.length),
- breakReason: .chapterEnd,
- blockRange: nil,
- attachmentRanges: attachmentRanges(in: content),
- attachmentKinds: [],
- blockKinds: [],
- semanticHints: [],
- attachmentPlacements: [],
- trailingFragmentID: nil,
- diagnostics: [
- "page break: chapterEnd",
- "cover fallback: single attachment page",
- "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
- ]
- )
- ]
- isCacheHit = false
- } else if let cached = cachedPagination?[item.href] {
- layoutFrames = cached.pageRanges.enumerated().map { idx, range in
- let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
- return RDEPUBTextLayoutFrame(
- contentRange: range,
- breakReason: breakReason,
- blockRange: nil,
- attachmentRanges: [],
- attachmentKinds: [],
- blockKinds: [],
- semanticHints: cached.semanticHints,
- attachmentPlacements: [],
- trailingFragmentID: nil,
- diagnostics: [
- "page break: \(breakReason.rawValue)",
- "page range: \(NSStringFromRange(range))",
- "source: cache hit"
- ]
- )
- }
- isCacheHit = true
- } else {
- layoutFrames = content.length > 0
- ? paginationPipeline.frames(
- for: content,
- pageSize: pageSize,
- config: layoutConfig,
- fragmentOffsets: rendered.fragmentOffsets
- )
- : []
- isCacheHit = false
- }
-
- let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
-
- let normalizedFrames = tailNormalizer.normalize(
- layoutFrames,
- content: content,
- href: item.href
- )
-
- let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
- ? [
- RDEPUBTextLayoutFrame(
- contentRange: NSRange(location: 0, length: content.length),
- breakReason: .chapterEnd,
- blockRange: nil,
- attachmentRanges: [],
- attachmentKinds: [],
- blockKinds: [],
- semanticHints: [],
- attachmentPlacements: [],
- trailingFragmentID: nil,
- diagnostics: [
- "page break: chapterEnd",
- "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
- ]
- )
- ]
- : normalizedFrames
-
- let chapterAttributedContent = content.copy() as! NSAttributedString
- let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
- let range = frame.contentRange
- return RDEPUBTextPage(
- absolutePageIndex: absolutePageStartIndex + localPageIndex,
- chapterIndex: chapterIndex,
- spineIndex: spineIndex,
- href: item.href,
- chapterTitle: chapterTitle,
- pageIndexInChapter: localPageIndex,
- totalPagesInChapter: effectiveFrames.count,
- chapterContent: chapterAttributedContent,
- contentRange: range,
- pageStartOffset: range.location,
- pageEndOffset: range.location + max(range.length - 1, 0),
- metadata: frame.metadata
- )
- }
-
- let chapter = RDEPUBTextChapter(
- chapterIndex: chapterIndex,
- spineIndex: spineIndex,
- href: item.href,
- title: chapterTitle,
- attributedContent: chapterAttributedContent,
- fragmentOffsets: rendered.fragmentOffsets,
- cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
- href: item.href,
- rawHTML: rawHTML,
- chapterText: chapterAttributedContent.string,
- fragmentOffsets: rendered.fragmentOffsets
- ),
- pageBreakReasons: pages.map(\.metadata.breakReason),
- pages: pages
- )
- let performanceSample = RDEPUBTextPerformanceSample(
- chapterHref: item.href,
- renderDuration: renderDuration,
- paginateDuration: paginateDuration,
- pageCount: effectiveFrames.count,
- attributedStringLength: content.length,
- cacheHit: isCacheHit
- )
- let diagnostic = diagnosticsReporter.chapterDiagnostic(
- href: item.href,
- title: chapterTitle,
- pages: pages
- )
-
- return RDEPUBTextChapterBuildResult(
- chapter: chapter,
- resourceDiagnostics: rendered.resourceDiagnostics,
- paginationDiagnostic: diagnostic,
- performanceSample: performanceSample,
- cacheHit: isCacheHit
- )
- }
-
- private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
- if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
- tocItem.href.components(separatedBy: "#").first == item.href
- })?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
- return title
- }
- let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmedTitle.isEmpty ? item.href : trimmedTitle
- }
-
- private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
- items.flatMap { item in
- [item] + flattenedTOCItems(from: item.children)
- }
- }
-
- private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
- let lowercasedHref = item.href.lowercased()
- var hasAttachment = false
- if content.length > 0 {
- content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
- guard value != nil else { return }
- hasAttachment = true
- stop.pointee = true
- }
- }
- if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
- return true
- }
- return false
- }
-
- private func attachmentCount(in content: NSAttributedString) -> Int {
- guard content.length > 0 else { return 0 }
- var count = 0
- content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
- if value != nil {
- count += 1
- }
- }
- return count
- }
-
- private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
- guard content.length > 0 else { return [] }
- var ranges: [NSRange] = []
- content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
- if value != nil {
- ranges.append(range)
- }
- }
- return ranges
- }
-
- private func isAttachmentOnlyCoverChapter(
- item: RDEPUBSpineItem,
- content: NSAttributedString,
- plainText: String
- ) -> Bool {
- let lowercasedHref = item.href.lowercased()
- guard lowercasedHref.contains("cover") else { return false }
- let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
- return attachmentCount(in: content) > 0 && trimmed.count <= 1
- }
-
- private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
-
- let collapsed = content.string
- .replacingOccurrences(of: "\n", with: " ")
- .replacingOccurrences(of: "\r", with: " ")
- .replacingOccurrences(of: "\t", with: " ")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- guard !collapsed.isEmpty else { return "" }
- if collapsed.count <= limit {
- return collapsed
- }
- let head = collapsed.prefix(limit)
- return "\(head)…"
- }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
deleted file mode 100644
index 6728e52..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
+++ /dev/null
@@ -1,205 +0,0 @@
-import Foundation
-import CryptoKit
-
-public struct RDEPUBTextChapterPaginationCache: Equatable {
-
- public var href: String
-
- public var pageRanges: [NSRange]
-
- public var breakReasons: [RDEPUBTextPageBreakReason]
-
- public var semanticHints: [RDEPUBTextSemanticHint]
-
- public init(
- href: String,
- pageRanges: [NSRange],
- breakReasons: [RDEPUBTextPageBreakReason],
- semanticHints: [RDEPUBTextSemanticHint]
- ) {
- self.href = href
- self.pageRanges = pageRanges
- self.breakReasons = breakReasons
- self.semanticHints = semanticHints
- }
-}
-
-final class PaginationCacheArchive: NSObject, NSSecureCoding {
-
- static var supportsSecureCoding: Bool { true }
-
- let chapters: [ChapterPaginationArchive]
-
- init(chapters: [ChapterPaginationArchive]) {
- self.chapters = chapters
- }
-
- func encode(with coder: NSCoder) {
- coder.encode(chapters, forKey: "chapters")
- }
-
- required init?(coder: NSCoder) {
- guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil }
- self.chapters = chapters
- }
-}
-
-final class ChapterPaginationArchive: NSObject, NSSecureCoding {
-
- static var supportsSecureCoding: Bool { true }
-
- let href: String
-
- let rangeLocations: [NSNumber]
-
- let rangeLengths: [NSNumber]
-
- let breakReasons: [String]
-
- let semanticHints: [String]
-
- init(from cache: RDEPUBTextChapterPaginationCache) {
- self.href = cache.href
- self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
- self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) }
- self.breakReasons = cache.breakReasons.map(\.rawValue)
- self.semanticHints = cache.semanticHints.map(\.rawValue)
- }
-
- func encode(with coder: NSCoder) {
- coder.encode(href, forKey: "href")
- coder.encode(rangeLocations, forKey: "rangeLocations")
- coder.encode(rangeLengths, forKey: "rangeLengths")
- coder.encode(breakReasons, forKey: "breakReasons")
- coder.encode(semanticHints, forKey: "semanticHints")
- }
-
- required init?(coder: NSCoder) {
- guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?,
- let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber],
- let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber],
- let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String],
- let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else {
- return nil
- }
- self.href = href
- self.rangeLocations = rangeLocations
- self.rangeLengths = rangeLengths
- self.breakReasons = breakReasons
- self.semanticHints = semanticHints
- }
-
- func toCache() -> RDEPUBTextChapterPaginationCache {
- let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
- NSRange(location: loc.intValue, length: len.intValue)
- }
- return RDEPUBTextChapterPaginationCache(
- href: href,
- pageRanges: pageRanges,
- breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)),
- semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:))
- )
- }
-}
-
-public final class RDEPUBTextBookCache {
-
- public var schemaVersion: Int = 14
-
- private let queue = DispatchQueue(label: "com.RDEpubReader.textbookcache", qos: .utility)
-
- private let cacheDirectory: URL
-
- public init(subdirectory: String = "RDEPUBTextBookCache") {
- let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
- .appendingPathComponent(subdirectory, isDirectory: true)
- ?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true)
- self.cacheDirectory = baseURL
- try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
- }
-
- public func cacheKey(
- bookID: String,
- fontSize: CGFloat,
- lineHeightMultiple: CGFloat,
- contentInsets: UIEdgeInsets,
- pageSize: CGSize,
- layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
- ) -> String {
- let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
- let digest = SHA256.hash(data: Data(raw.utf8))
- let hex = digest.map { String(format: "%02x", $0) }.joined()
- return hex + ".cache"
- }
-
- public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
- queue.sync {
- let fileURL = cacheDirectory.appendingPathComponent(key)
- guard FileManager.default.fileExists(atPath: fileURL.path) else {
- #if DEBUG
-// print("[Cache] load MISS key=\(key)")
- #endif
- return nil
- }
- do {
- let data = try Data(contentsOf: fileURL)
- guard let archive = try NSKeyedUnarchiver.unarchivedObject(
- ofClass: PaginationCacheArchive.self,
- from: data
- ) else {
- #if DEBUG
-// print("[Cache] load MISS key=\(key) (unarchive returned nil)")
- #endif
- return nil
- }
- var result: [String: RDEPUBTextChapterPaginationCache] = [:]
- for chapter in archive.chapters {
- result[chapter.href] = chapter.toCache()
- }
- #if DEBUG
-// print("[Cache] load HIT key=\(key) chapters=\(result.count)")
- #endif
- return result
- } catch {
- #if DEBUG
-// print("[Cache] load MISS key=\(key) error=\(error)")
- #endif
- return nil
- }
- }
- }
-
- public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
- queue.sync {
- let fileURL = cacheDirectory.appendingPathComponent(key)
- do {
- let archives = chapters.map { ChapterPaginationArchive(from: $0) }
- let bookArchive = PaginationCacheArchive(chapters: archives)
- let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
- try data.write(to: fileURL, options: .atomic)
- #if DEBUG
- print("[Cache] save key=\(key) chapters=\(chapters.count)")
- #endif
- } catch {
- #if DEBUG
- print("[Cache] save FAILED key=\(key) error=\(error)")
- #endif
- }
- }
- }
-
- public func invalidateAll() {
- queue.sync {
- let fileManager = FileManager.default
- guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else {
- return
- }
- for file in contents {
- try? fileManager.removeItem(at: file)
- }
- #if DEBUG
- print("[Cache] invalidateAll")
- #endif
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
deleted file mode 100644
index bf24607..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
+++ /dev/null
@@ -1,193 +0,0 @@
-import UIKit
-
-public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
-
- public var href: String
-
- public var title: String
-
- public var pageCount: Int
-
- public var breakReasons: [RDEPUBTextPageBreakReason]
-
- public var attachmentPageCount: Int
-
- public var blockAdjustedPageCount: Int
-
- public var blockKinds: [RDEPUBTextBlockKind]
-
- public var semanticHints: [RDEPUBTextSemanticHint]
-
- public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
-
- public var sampleNotes: [String]
-}
-
-public struct RDEPUBTextChapterBuildResult {
-
- public var chapter: RDEPUBTextChapter
-
- public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
-
- public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
-
- public var performanceSample: RDEPUBTextPerformanceSample
-
- public var cacheHit: Bool
-}
-
-public struct RDEPUBTextPage: Equatable {
-
- public var absolutePageIndex: Int
-
- public var chapterIndex: Int
-
- public var spineIndex: Int
-
- public var href: String
-
- public var chapterTitle: String
-
- public var pageIndexInChapter: Int
-
- public var totalPagesInChapter: Int
-
- public var chapterContent: NSAttributedString
-
- /// Page substring derived on demand from `chapterContent` + `contentRange`.
- /// Not stored: keeping a per-page substring alive roughly doubles the
- /// chapter's resident text memory across the chapter cache window.
- public var content: NSAttributedString {
- let bounds = NSRange(location: 0, length: chapterContent.length)
- let clamped = NSIntersectionRange(contentRange, bounds)
- guard clamped.length > 0 else { return NSAttributedString() }
- return chapterContent.attributedSubstring(from: clamped)
- }
-
- public var contentRange: NSRange
-
- public var pageStartOffset: Int
-
- public var pageEndOffset: Int
-
- public var metadata: RDEPUBTextPageMetadata
-}
-
-public struct RDEPUBTextChapter: Equatable {
-
- public var chapterIndex: Int
-
- public var spineIndex: Int
-
- public var href: String
-
- public var title: String
-
- public var attributedContent: NSAttributedString
-
- public var fragmentOffsets: [String: Int]
-
- public var cfiMap: RDEPUBCFIMap?
-
- public var pageBreakReasons: [RDEPUBTextPageBreakReason]
-
- public var pages: [RDEPUBTextPage]
-}
-
-public struct RDEPUBTextBook {
-
- public var chapters: [RDEPUBTextChapter]
-
- public var pages: [RDEPUBTextPage]
-
- public let indexTable: RDEPUBTextIndexTable
-
- public var positionConverter: RDEPUBTextPositionConverter {
- RDEPUBTextPositionConverter(book: self)
- }
-
- public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
- self.chapters = chapters
- self.pages = pages
- self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
- }
-
- public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
- lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
- }
-
- public func chapterData(for href: String) -> RDEPUBChapterData? {
- guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
- return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
- }
-
- public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
- guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
- return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
- }
-
- public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
- guard chapters.indices.contains(index) else { return nil }
- return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
- }
-
- public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
- guard let page = page(at: pageNumber) else { return nil }
- return chapterData(forSpineIndex: page.spineIndex)
- }
-
- public func chapterData(
- for location: RDEPUBLocation,
- resolver: RDEPUBResourceResolver,
- bookIdentifier: String?
- ) -> RDEPUBChapterData? {
- guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
- return nil
- }
- return chapterData(for: normalizedLocation.href)
- }
-
- public var chapterInfos: [EPUBChapterInfo] {
- chapters.map { chapter in
- EPUBChapterInfo(
- spineIndex: chapter.spineIndex,
- title: chapter.title,
- pageCount: chapter.pages.count
- )
- }
- }
-
- public func page(at pageNumber: Int) -> RDEPUBTextPage? {
- guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
- return nil
- }
- return pages[pageNumber - 1]
- }
-
- public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
- guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
- let chapterData = chapterData(for: normalizedLocation.href) else {
- return nil
- }
-
- if let anchor = normalizedLocation.rangeAnchor?.start,
- let page = positionConverter.pageNumber(for: anchor) {
- return page
- }
-
- if let anchor = indexTable.anchor(for: normalizedLocation),
- let page = positionConverter.pageNumber(for: anchor) {
- return page
- }
-
- return chapterData.pageNumber(for: normalizedLocation)
- }
-
- public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
- guard let chapterData = chapterData(forPageNumber: pageNumber),
- let page = page(at: pageNumber) else {
- return nil
- }
- return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift
deleted file mode 100644
index 19074f6..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-
-import UIKit
-
-protocol RDEPUBTextBookBuilding {
-
- func build(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBTextBook
-}
-
-struct RDEPUBChapterRenderPipeline {
- private let renderer: RDEPUBTextRenderer
-
- init(renderer: RDEPUBTextRenderer) {
- self.renderer = renderer
- }
-
- func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
- try renderer.renderChapter(request: request)
- }
-}
-
-struct RDEPUBChapterPaginationPipeline {
- private let frameFactory: RDEPUBPageFrameBuilding
-
- init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
- self.frameFactory = frameFactory
- }
-
- func frames(
- for content: NSAttributedString,
- pageSize: CGSize,
- config: RDEPUBTextLayoutConfig,
- fragmentOffsets: [String: Int]
- ) -> [RDEPUBTextLayoutFrame] {
- frameFactory.makeFrames(
- attributedString: content,
- pageSize: pageSize,
- config: config,
- fragmentOffsets: fragmentOffsets
- )
- }
-}
-
-extension RDEPUBTextBookBuilder: RDEPUBTextBookBuilding {}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift
deleted file mode 100644
index ac974d5..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-import Foundation
-
-public struct RDEPUBTextPerformanceSample: Equatable {
-
- public var chapterHref: String
-
- public var renderDuration: TimeInterval
-
- public var paginateDuration: TimeInterval
-
- public var pageCount: Int
-
- public var attributedStringLength: Int
-
- public var cacheHit: Bool
-
- public init(
- chapterHref: String,
- renderDuration: TimeInterval,
- paginateDuration: TimeInterval,
- pageCount: Int,
- attributedStringLength: Int,
- cacheHit: Bool
- ) {
- self.chapterHref = chapterHref
- self.renderDuration = renderDuration
- self.paginateDuration = paginateDuration
- self.pageCount = pageCount
- self.attributedStringLength = attributedStringLength
- self.cacheHit = cacheHit
- }
-}
-
-public final class RDEPUBTextPerformanceSampler {
-
- public private(set) var samples: [RDEPUBTextPerformanceSample] = []
-
- public var totalBuildDuration: TimeInterval = 0
-
- public init() {}
-
- public func record(_ sample: RDEPUBTextPerformanceSample) {
- samples.append(sample)
- #if DEBUG
- print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
- #endif
- }
-
- public func summary() -> String {
- let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
- let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
- let hitCount = samples.filter(\.cacheHit).count
- return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
- }
-
- public func reset() {
- samples.removeAll()
- totalBuildDuration = 0
- }
-
- private func formatMS(_ duration: TimeInterval) -> String {
- String(format: "%.0fms", duration * 1000)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
deleted file mode 100644
index b1bdff4..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
+++ /dev/null
@@ -1,271 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBChapterPageCounter {
-
- private let factory: RDEPUBCoreTextPageFrameFactory
-
- private let attributedString: NSAttributedString
-
- private let pageSize: CGSize
-
- private let config: RDEPUBTextLayoutConfig
-
- private let pageBreakPolicy: RDEPUBPageBreakPolicy
-
- private let framesetter: CTFramesetter
-
- private let dtLayoutRect: CGRect
-
- init(factory: RDEPUBCoreTextPageFrameFactory) {
- self.factory = factory
- self.attributedString = factory.attributedString
- self.pageSize = factory.pageSize
- self.config = factory.config
- self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
- self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
- self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
- }
-
- func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
- guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
- return []
- }
-
-#if canImport(DTCoreText)
- return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
-#else
- return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
-#endif
- }
-
- private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
- guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
- return []
- }
-
- var frames: [RDEPUBTextLayoutFrame] = []
- var location = 0
- let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
- let contentRect = config.contentRect(fallback: resolvedSize)
-
- guard contentRect.width > 0, contentRect.height > 0 else {
- return []
- }
-
- while location < attributedString.length {
- let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
- pageSize: resolvedSize,
- config: config
- )
- let frame = CTFramesetterCreateFrame(
- framesetter,
- CFRangeMake(location, 0),
- framePath,
- nil
- )
- let proposedRange = proposedVisibleRange(
- from: frame,
- start: location,
- totalLength: attributedString.length
- )
- guard proposedRange.length > 0 else {
- break
- }
-
- let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
- let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
- let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
- let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
- proposed: keepWithNextAdjusted,
- lineRanges: lineRanges
- )
- let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
-
- let adjusted = pageBreakPolicy.adjustedRange(
- from: widowOrphanAdjusted,
- totalLength: attributedString.length,
- lineRanges: effectiveLineRanges,
- factory: factory
- )
- let trailingFragmentID = factory.nearestTrailingFragmentID(
- endingAt: adjusted.range.location + adjusted.range.length,
- fragmentOffsets: fragmentOffsets
- )
- frames.append(
- RDEPUBTextLayoutFrame(
- contentRange: adjusted.range,
- breakReason: adjusted.breakReason,
- blockRange: adjusted.blockRange,
- attachmentRanges: adjusted.attachmentRanges,
- attachmentKinds: adjusted.attachmentKinds,
- blockKinds: adjusted.blockKinds,
- semanticHints: adjusted.semanticHints,
- attachmentPlacements: adjusted.attachmentPlacements,
- trailingFragmentID: trailingFragmentID,
- diagnostics: adjusted.diagnostics
- )
- )
-
- let nextLocation = adjusted.range.location + adjusted.range.length
- guard nextLocation > location else {
- location += max(proposedRange.length, 1)
- continue
- }
- location = nextLocation
- }
-
- return frames
- }
-
-#if canImport(DTCoreText)
-
- private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
- guard config.numberOfColumns == 1 else {
- return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
- }
-
- guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
- return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
- }
-
- layouter.shouldCacheLayoutFrames = false
-
- var frames: [RDEPUBTextLayoutFrame] = []
- var location = 0
- let pageRect = dtLayoutRect
- let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
- if isDebug {
- print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
- }
-
- while location < attributedString.length {
- guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
- break
- }
-
- let visibleRange = layoutFrame.visibleStringRange()
- guard visibleRange.length > 0 else {
- break
- }
-
- let proposedRange = NSRange(location: location, length: visibleRange.length)
- let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
- let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
- let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
- let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
- proposed: lineAdjusted,
- lineRanges: lineRanges
- )
-
- if isDebug {
- let pageCount = frames.count + 1
- let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
- let avoidRemoved = proposedRange.length - avoidAdjusted.length
- let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
- let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
- let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
- print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
- }
- let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
- let adjusted = pageBreakPolicy.adjustedRange(
- from: widowOrphanAdjusted,
- totalLength: attributedString.length,
- lineRanges: effectiveLineRanges,
- factory: factory
- )
- let verifiedRange: NSRange
- if adjusted.breakReason == .attachmentBoundary {
- verifiedRange = verifiedDisplayRange(for: adjusted.range)
- } else {
- verifiedRange = adjusted.range
- }
- let trailingFragmentID = factory.nearestTrailingFragmentID(
- endingAt: verifiedRange.location + verifiedRange.length,
- fragmentOffsets: fragmentOffsets
- )
- let diagnostics = verifiedRange == adjusted.range
- ? adjusted.diagnostics
- : adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
-
- frames.append(
- RDEPUBTextLayoutFrame(
- contentRange: verifiedRange,
- breakReason: adjusted.breakReason,
- blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
- attachmentRanges: factory.attachmentRanges(in: verifiedRange),
- attachmentKinds: factory.attachmentKinds(in: verifiedRange),
- blockKinds: factory.blockKinds(in: verifiedRange),
- semanticHints: factory.semanticHints(in: verifiedRange),
- attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
- trailingFragmentID: trailingFragmentID,
- diagnostics: diagnostics
- )
- )
-
- let nextLocation = verifiedRange.location + verifiedRange.length
- guard nextLocation > location else {
- location += max(visibleRange.length, 1)
- continue
- }
- location = nextLocation
- }
-
- return frames
- }
-
- private func verifiedDisplayRange(for range: NSRange) -> NSRange {
- guard let clampedRange = factory.clampedRange(range),
- clampedRange.length > 0,
- !factory.attachmentRanges(in: clampedRange).isEmpty else {
- return range
- }
-
- let pageContent = attributedString.attributedSubstring(from: clampedRange)
- guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
- return clampedRange
- }
-
- layouter.shouldCacheLayoutFrames = false
- guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
- return clampedRange
- }
-
- let visibleRange = layoutFrame.visibleStringRange()
- guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
- return clampedRange
- }
-
- return NSRange(location: clampedRange.location, length: visibleRange.length)
- }
-#endif
-
- private func proposedVisibleRange(
- from frame: CTFrame,
- start location: Int,
- totalLength: Int
- ) -> NSRange {
- let visibleRange = CTFrameGetVisibleStringRange(frame)
- guard visibleRange.length > 0 else {
- return NSRange(location: location, length: 0)
- }
-
- let resolvedLocation = max(location, visibleRange.location)
- let visibleEnd = visibleRange.location + visibleRange.length
- let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
- guard length > 0 else {
- return NSRange(location: resolvedLocation, length: 0)
- }
- return NSRange(location: resolvedLocation, length: length)
- }
-
- private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
- lineRanges.filter { lineRange in
- lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
deleted file mode 100644
index 893fcdc..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
+++ /dev/null
@@ -1,499 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
-
- let attributedString: NSAttributedString
-
- let pageSize: CGSize
-
- let config: RDEPUBTextLayoutConfig
-
- private let pageBreakPolicy: RDEPUBPageBreakPolicy
-
- init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
- self.attributedString = attributedString
- self.pageSize = pageSize
- self.config = config
- self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
- }
-
- init() {
- self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
- }
-
- func makeFrames(
- attributedString: NSAttributedString,
- pageSize: CGSize,
- config: RDEPUBTextLayoutConfig,
- fragmentOffsets: [String: Int]
- ) -> [RDEPUBTextLayoutFrame] {
- let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
- let counter = RDEPUBChapterPageCounter(factory: factory)
- return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
- }
-
- static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
- let columnRects = config.columnRects(fallback: pageSize)
- guard columnRects.count > 1 else {
- return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
- }
-
- let path = CGMutablePath()
- for rect in columnRects {
- path.addRect(rect)
- }
- return path
- }
-
- func trimmedRangeForAvoidPageBreakInside(
- from frame: CTFrame,
- proposed: NSRange
- ) -> NSRange {
- guard config.avoidPageBreakInsideEnabled else { return proposed }
-
- let lines = CTFrameGetLines(frame) as! [CTLine]
- guard !lines.isEmpty else { return proposed }
-
- let lineRanges = lines.map {
- let range = CTLineGetStringRange($0)
- return NSRange(location: range.location, length: range.length)
- }
- return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
- }
-
- func trimmedRangeForKeepWithNext(
- from frame: CTFrame,
- proposed: NSRange
- ) -> NSRange {
- let lines = CTFrameGetLines(frame) as! [CTLine]
- let lineRanges = lines.map {
- let range = CTLineGetStringRange($0)
- return NSRange(location: range.location, length: range.length)
- }
- return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
- }
-
-#if canImport(DTCoreText)
-
- func trimmedRangeForAvoidPageBreakInside(
- from layoutFrame: DTCoreTextLayoutFrame,
- proposed: NSRange
- ) -> NSRange {
- guard config.avoidPageBreakInsideEnabled else { return proposed }
-
- guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
- return proposed
- }
- let lineRanges = lines.map { $0.stringRange() }
- return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
- }
-
- func trimmedRangeForKeepWithNext(
- from layoutFrame: DTCoreTextLayoutFrame,
- proposed: NSRange
- ) -> NSRange {
- guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
- return proposed
- }
- let lineRanges = lines.map { $0.stringRange() }
- return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
- }
-#endif
-
- func trimmedRangeForAvoidPageBreakInside(
- proposed: NSRange,
- lineRanges: [NSRange]
- ) -> NSRange {
- guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
-
- let kMaxLinesToRemove = 3
- var linesToRemove = 0
-
- for lineRange in lineRanges.reversed() {
- if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(lineRange) {
- linesToRemove += 1
- if linesToRemove >= kMaxLinesToRemove {
- linesToRemove = kMaxLinesToRemove
- break
- }
- } else {
- break
- }
- }
-
- guard linesToRemove > 0 else { return proposed }
-
- let validLineCount = lineRanges.count - linesToRemove
- guard validLineCount > 0 else { return proposed }
-
- let lastValidLine = lineRanges[validLineCount - 1]
- let endLocation = lastValidLine.location + lastValidLine.length
- let adjustedLength = endLocation - proposed.location
- guard adjustedLength > 0 else { return proposed }
-
- if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
- let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
- print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
- }
-
- return NSRange(location: proposed.location, length: adjustedLength)
- }
-
- func trimmedRangeForKeepWithNext(
- proposed: NSRange,
- lineRanges: [NSRange]
- ) -> NSRange {
- guard !lineRanges.isEmpty else { return proposed }
-
- let kMaxLinesToRemove = 3
- var linesToRemove = 0
-
- for lineRange in lineRanges.reversed() {
- if pageBreakPolicy.lineIsInKeepWithNextBlock(lineRange) {
- linesToRemove += 1
- if linesToRemove >= kMaxLinesToRemove {
- linesToRemove = kMaxLinesToRemove
- break
- }
- } else {
- break
- }
- }
-
- guard linesToRemove > 0 else { return proposed }
-
- let validLineCount = lineRanges.count - linesToRemove
- guard validLineCount > 0 else { return proposed }
-
- let lastValidLine = lineRanges[validLineCount - 1]
- let endLocation = lastValidLine.location + lastValidLine.length
- let adjustedLength = endLocation - proposed.location
- guard adjustedLength > 0 else { return proposed }
-
- if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
- let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
- print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
- }
-
- return NSRange(location: proposed.location, length: adjustedLength)
- }
-
- func trimmedRangeForWidowAndOrphanControl(
- proposed: NSRange,
- lineRanges: [NSRange]
- ) -> NSRange {
- guard !lineRanges.isEmpty else { return proposed }
- guard config.avoidWidows || config.avoidOrphans else { return proposed }
-
- var adjusted = proposed
- var visibleLines = lineRanges
-
- if config.avoidWidows,
- let widowAdjusted = trimmedRangeAvoidingWidow(
- proposed: adjusted,
- lineRanges: visibleLines
- ),
- widowAdjusted != adjusted {
- adjusted = widowAdjusted
- visibleLines = Array(visibleLines.dropLast())
- }
-
- if config.avoidOrphans,
- let orphanAdjusted = trimmedRangeAvoidingOrphan(
- proposed: adjusted,
- lineRanges: visibleLines
- ) {
- adjusted = orphanAdjusted
- }
-
- return adjusted
- }
-
- static func lineRanges(from frame: CTFrame) -> [NSRange] {
- let lines = CTFrameGetLines(frame) as! [CTLine]
- return lines.map {
- let lineRange = CTLineGetStringRange($0)
- return NSRange(location: lineRange.location, length: lineRange.length)
- }
- }
-
-#if canImport(DTCoreText)
-
- static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
- guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
- return []
- }
- return lines.map { $0.stringRange() }
- }
-#endif
-
- func blockRange(at location: Int) -> NSRange? {
- guard location >= 0, location < attributedString.length else { return nil }
- let attributes = attributedString.attributes(at: location, effectiveRange: nil)
- if let encodedRange = attributes[.rdPageBlockRange] as? String {
- return NSRangeFromString(encodedRange)
- }
- return nil
- }
-
- func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
- guard location >= 0, location < attributedString.length else { return nil }
- let attributes = attributedString.attributes(at: location, effectiveRange: nil)
- guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
- return RDEPUBTextBlockKind(rawValue: rawValue)
- }
-
- func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
- guard location >= 0, location < attributedString.length else { return nil }
- let attributes = attributedString.attributes(at: location, effectiveRange: nil)
- guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
- return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
- }
-
- func paragraphRange(containing location: Int) -> NSRange {
- let source = attributedString.string as NSString
- guard source.length > 0 else { return NSRange(location: 0, length: 0) }
- let safeLocation = min(max(location, 0), max(source.length - 1, 0))
- return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
- }
-
- func attachmentRanges(in range: NSRange) -> [NSRange] {
- guard let safeRange = clampedRange(range), safeRange.length > 0 else {
- return []
- }
- var results: [NSRange] = []
- attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
- guard value != nil else { return }
- results.append(attributeRange)
- }
- return results
- }
-
- func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
- guard location >= 0, location < attributedString.length else { return [] }
- let attributes = attributedString.attributes(at: location, effectiveRange: nil)
- guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
- return rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- }
-
- private func trimmedRangeAvoidingWidow(
- proposed: NSRange,
- lineRanges: [NSRange]
- ) -> NSRange? {
- guard lineRanges.count >= 2,
- let lastLine = lineRanges.last else {
- return nil
- }
-
- let paragraphRange = paragraphRange(containing: lastLine.location)
- let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
- let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
-
- guard trailingLines == 1, paragraphContinuesOnNextPage else {
- return nil
- }
-
- let keptLine = lineRanges[lineRanges.count - 2]
- let adjustedLength = NSMaxRange(keptLine) - proposed.location
- guard adjustedLength > 0 else { return nil }
-
- if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
- let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
- print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
- }
-
- return NSRange(location: proposed.location, length: adjustedLength)
- }
-
- private func trimmedRangeAvoidingOrphan(
- proposed: NSRange,
- lineRanges: [NSRange]
- ) -> NSRange? {
- guard lineRanges.count >= 2,
- let lastLine = lineRanges.last else {
- return nil
- }
-
- let paragraphRange = paragraphRange(containing: lastLine.location)
- let pageEnd = NSMaxRange(proposed)
- let paragraphEnd = NSMaxRange(paragraphRange)
- guard pageEnd < paragraphEnd else { return nil }
-
- let remainingLineCount = estimatedRemainingLineCount(
- in: paragraphRange,
- startingAt: pageEnd
- )
- let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
- guard remainingLineCount == 1,
- trailingLines >= 2 else {
- return nil
- }
-
- let keptLine = lineRanges[lineRanges.count - 2]
- let adjustedLength = NSMaxRange(keptLine) - proposed.location
- guard adjustedLength > 0 else { return nil }
- return NSRange(location: proposed.location, length: adjustedLength)
- }
-
- private func trailingLineCount(
- in lineRanges: [NSRange],
- paragraphRange: NSRange
- ) -> Int {
- var count = 0
- for lineRange in lineRanges.reversed() {
- if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
- count += 1
- } else {
- break
- }
- }
- return count
- }
-
- private func estimatedRemainingLineCount(
- in paragraphRange: NSRange,
- startingAt location: Int
- ) -> Int {
- let paragraphEnd = NSMaxRange(paragraphRange)
- var currentLocation = max(location, paragraphRange.location)
- guard currentLocation < paragraphEnd else { return 0 }
-
- let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
- let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
- var lineCount = 0
-
- while currentLocation < paragraphEnd {
- let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
- let lineLength = max(suggestedCount, 1)
- currentLocation += lineLength
- lineCount += 1
- if lineCount > 2 {
- break
- }
- }
-
- return lineCount
- }
-
- func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
- guard let safeRange = clampedRange(range), safeRange.length > 0 else {
- return []
- }
- var kinds: [RDEPUBTextAttachmentKind] = []
- attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
- guard let rawValue = value as? String,
- let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
- !kinds.contains(kind) else {
- return
- }
- kinds.append(kind)
- }
- return kinds
- }
-
- func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
- guard let safeRange = clampedRange(range), safeRange.length > 0 else {
- return []
- }
- var kinds: [RDEPUBTextBlockKind] = []
- attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
- guard let rawValue = value as? String,
- let kind = RDEPUBTextBlockKind(rawValue: rawValue),
- !kinds.contains(kind) else {
- return
- }
- kinds.append(kind)
- }
- return kinds
- }
-
- func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
- guard let safeRange = clampedRange(range), safeRange.length > 0 else {
- return []
- }
- var hints: [RDEPUBTextSemanticHint] = []
- attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
- guard let rawValue = value as? String else { return }
- for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
- hints.append(hint)
- }
- }
- return hints
- }
-
- func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
- guard let safeRange = clampedRange(range), safeRange.length > 0 else {
- return []
- }
- var placements: [RDEPUBTextAttachmentPlacement] = []
- attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
- guard let rawValue = value as? String,
- let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
- !placements.contains(placement) else {
- return
- }
- placements.append(placement)
- }
- return placements
- }
-
- func clampedRange(_ range: NSRange) -> NSRange? {
- guard range.location >= 0, range.length >= 0 else { return nil }
- guard attributedString.length > 0 else {
- return range.location == 0 ? NSRange(location: 0, length: 0) : nil
- }
- guard range.location < attributedString.length else { return nil }
-
- let maxLength = attributedString.length - range.location
- return NSRange(location: range.location, length: min(range.length, maxLength))
- }
-
- func nearestTrailingFragmentID(
- endingAt location: Int,
- fragmentOffsets: [String: Int]
- ) -> String? {
- fragmentOffsets
- .filter { $0.value <= location }
- .max { lhs, rhs in lhs.value < rhs.value }?
- .key
- }
-
- func diagnostics(
- reason: RDEPUBTextPageBreakReason,
- range: NSRange,
- attachmentRanges: [NSRange],
- blockRange: NSRange?,
- blockKinds: [RDEPUBTextBlockKind],
- semanticHints: [RDEPUBTextSemanticHint],
- attachmentPlacements: [RDEPUBTextAttachmentPlacement],
- trigger: String? = nil
- ) -> [String] {
- var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
- if let blockRange {
- items.append("block range: \(NSStringFromRange(blockRange))")
- }
- if !attachmentRanges.isEmpty {
- items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
- }
- if !blockKinds.isEmpty {
- items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
- }
- if !semanticHints.isEmpty {
- items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
- }
- if !attachmentPlacements.isEmpty {
- items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
- }
- if let trigger, !trigger.isEmpty {
- items.append("semantic trigger: \(trigger)")
- }
- return items
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
deleted file mode 100644
index bb3cf38..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
+++ /dev/null
@@ -1,217 +0,0 @@
-import Foundation
-import UIKit
-
-struct RDEPUBPageBreakPolicy {
-
- private let attributedString: NSAttributedString
-
- init(attributedString: NSAttributedString) {
- self.attributedString = attributedString
- }
-
- func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
- guard let probeRange = clampedProbeRange(for: lineRange) else {
- return false
- }
- var found = false
- attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
- guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
- return
- }
- guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- if hints.contains(.avoidPageBreakInside) {
- found = true
- stop.pointee = true
- }
- }
- return found
- }
-
- func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
- guard let probeRange = clampedProbeRange(for: lineRange) else {
- return false
- }
- var found = false
- attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
- guard let rawValue = value as? String else { return }
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- if hints.contains(.keepWithNext) {
- found = true
- stop.pointee = true
- }
- }
- return found
- }
-
- func adjustedRange(
- from proposedRange: NSRange,
- totalLength: Int,
- lineRanges: [NSRange],
- factory: RDEPUBCoreTextPageFrameFactory
- ) -> (
- range: NSRange,
- breakReason: RDEPUBTextPageBreakReason,
- blockRange: NSRange?,
- attachmentRanges: [NSRange],
- attachmentKinds: [RDEPUBTextAttachmentKind],
- blockKinds: [RDEPUBTextBlockKind],
- semanticHints: [RDEPUBTextSemanticHint],
- attachmentPlacements: [RDEPUBTextAttachmentPlacement],
- diagnostics: [String]
- ) {
- let pageEnd = proposedRange.location + proposedRange.length
- let proposedBlockKinds = factory.blockKinds(in: proposedRange)
- let proposedSemanticHints = factory.semanticHints(in: proposedRange)
- let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
- guard pageEnd < totalLength else {
- return (
- range: proposedRange,
- breakReason: .chapterEnd,
- blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
- attachmentRanges: factory.attachmentRanges(in: proposedRange),
- attachmentKinds: factory.attachmentKinds(in: proposedRange),
- blockKinds: proposedBlockKinds,
- semanticHints: proposedSemanticHints,
- attachmentPlacements: proposedAttachmentPlacements,
- diagnostics: factory.diagnostics(
- reason: .chapterEnd,
- range: proposedRange,
- attachmentRanges: factory.attachmentRanges(in: proposedRange),
- blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
- blockKinds: proposedBlockKinds,
- semanticHints: proposedSemanticHints,
- attachmentPlacements: proposedAttachmentPlacements
- )
- )
- }
-
- let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
- let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
- let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
- let currentBlockKinds = proposedBlockKinds
- let currentSemanticHints = proposedSemanticHints
- let currentAttachmentPlacements = proposedAttachmentPlacements
-
- if let pageRelateBoundary = preferredPageRelateBoundary(
- after: proposedRange,
- minimumEnd: proposedRange.location + 1,
- lineRanges: lineRanges,
- factory: factory
- ) {
- let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
- return (
- range: adjustedRange,
- breakReason: .semanticBoundary,
- blockRange: currentBlockRange,
- attachmentRanges: currentAttachmentRanges,
- attachmentKinds: currentAttachmentKinds,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements,
- diagnostics: factory.diagnostics(
- reason: .semanticBoundary,
- range: adjustedRange,
- attachmentRanges: currentAttachmentRanges,
- blockRange: currentBlockRange,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements,
- trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
- )
- )
- }
-
- return (
- range: proposedRange,
- breakReason: .frameLimit,
- blockRange: currentBlockRange,
- attachmentRanges: currentAttachmentRanges,
- attachmentKinds: currentAttachmentKinds,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements,
- diagnostics: factory.diagnostics(
- reason: .frameLimit,
- range: proposedRange,
- attachmentRanges: currentAttachmentRanges,
- blockRange: currentBlockRange,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements
- )
- )
- }
-
- func preferredPageRelateBoundary(
- after range: NSRange,
- minimumEnd: Int,
- lineRanges: [NSRange],
- factory: RDEPUBCoreTextPageFrameFactory
- ) -> Int? {
- let pageStartOfNext = range.location + range.length
- guard pageStartOfNext > range.location,
- pageStartOfNext < attributedString.length,
- lineRanges.count >= 2,
- factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
- return nil
- }
-
- let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
- ?? factory.paragraphRange(containing: pageStartOfNext).location
- guard boundaryBlockStart == pageStartOfNext else { return nil }
-
- let lastLineStart = lineRanges[lineRanges.count - 1].location
- guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
- return nil
- }
- return lastLineStart
- }
-
- private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
- guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
- return false
- }
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- guard hints.contains(.avoidPageBreakInside) else {
- return false
- }
-
- let placement = (attributes[.rdPageAttachmentPlacement] as? String)
- .flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
- let blockKind = (attributes[.rdPageBlockKind] as? String)
- .flatMap(RDEPUBTextBlockKind.init(rawValue:))
-
- // Inline attachments (footnote icons, rare-character images) flow with
- // the surrounding text, so their avoid hint must not lock the line to
- // the next page. Placement is checked besides blockKind because an
- // enclosing paragraph's semantics overwrite blockKind on the
- // attachment's range, while placement survives.
- if blockKind == .attachment || placement != nil, placement != .centered {
- return false
- }
-
- return true
- }
-
- private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
- clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
- }
-
- private func clampedRange(_ range: NSRange) -> NSRange? {
- guard range.location >= 0, range.length >= 0 else { return nil }
- guard attributedString.length > 0 else {
- return range.location == 0 ? NSRange(location: 0, length: 0) : nil
- }
- guard range.location < attributedString.length else { return nil }
-
- let maxLength = attributedString.length - range.location
- return NSRange(location: range.location, length: min(range.length, maxLength))
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift
deleted file mode 100644
index ef5c0d7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-import Foundation
-
-struct RDEPUBTextLayoutFrame: Equatable {
-
- var contentRange: NSRange
-
- var breakReason: RDEPUBTextPageBreakReason
-
- var blockRange: NSRange?
-
- var attachmentRanges: [NSRange]
-
- var attachmentKinds: [RDEPUBTextAttachmentKind]
-
- var blockKinds: [RDEPUBTextBlockKind]
-
- var semanticHints: [RDEPUBTextSemanticHint]
-
- var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
-
- var trailingFragmentID: String?
-
- var diagnostics: [String]
-
- var metadata: RDEPUBTextPageMetadata {
- RDEPUBTextPageMetadata(
- breakReason: breakReason,
- blockRange: blockRange,
- attachmentRanges: attachmentRanges,
- attachmentKinds: attachmentKinds,
- blockKinds: blockKinds,
- semanticHints: semanticHints,
- attachmentPlacements: attachmentPlacements,
- trailingFragmentID: trailingFragmentID,
- diagnostics: diagnostics
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift
deleted file mode 100644
index d87f9ed..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBTextLayouter {
- private let counter: RDEPUBChapterPageCounter
-
- init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
- let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
- self.counter = RDEPUBChapterPageCounter(factory: factory)
- }
-
- func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
- counter.layoutFrames(fragmentOffsets: fragmentOffsets)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift
deleted file mode 100644
index 257acbe..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-
-import Foundation
-import UIKit
-
-struct RDEPUBPageBreakDecision {
-
- var range: NSRange
-
- var reason: RDEPUBTextPageBreakReason
-
- var diagnostics: [String]
-}
-
-protocol RDEPUBChapterPageCounting {
-
- func pageRanges(
- for attributedString: NSAttributedString,
- pageSize: CGSize,
- config: RDEPUBTextLayoutConfig,
- fragmentOffsets: [String: Int]
- ) -> [RDEPUBPageBreakDecision]
-}
-
-protocol RDEPUBPageFrameBuilding {
-
- func makeFrames(
- attributedString: NSAttributedString,
- pageSize: CGSize,
- config: RDEPUBTextLayoutConfig,
- fragmentOffsets: [String: Int]
- ) -> [RDEPUBTextLayoutFrame]
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift
deleted file mode 100644
index a7ab73c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import CoreText
-import UIKit
-
-extension NSAttributedString {
-
- func rd_paginatedFrames(
- size: CGSize,
- fragmentOffsets: [String: Int] = [:],
- config: RDEPUBTextLayoutConfig = .default
- ) -> [RDEPUBTextLayoutFrame] {
- let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
- let counter = RDEPUBChapterPageCounter(factory: factory)
- return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
- }
-
- func ss_pageRanges(size: CGSize) -> [NSRange] {
- rd_paginatedFrames(size: size).map(\.contentRange)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift
deleted file mode 100644
index cdd4d58..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift
+++ /dev/null
@@ -1,380 +0,0 @@
-import UIKit
-
-public final class RDEPUBChapterData {
-
- public let chapter: RDEPUBTextChapter
-
- public let indexTable: RDEPUBTextIndexTable
-
- public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
- self.chapter = chapter
- self.indexTable = indexTable
- }
-
- public var chapterIndex: Int { chapter.chapterIndex }
-
- public var spineIndex: Int { chapter.spineIndex }
-
- public var href: String { chapter.href }
-
- public var title: String { chapter.title }
-
- public var attributedContent: NSAttributedString { chapter.attributedContent }
-
- public var pages: [RDEPUBTextPage] { chapter.pages }
-
- public var pageCount: Int { chapter.pages.count }
-
- public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
-
- public var chapterInfo: EPUBChapterInfo {
- EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
- }
-
- public var absolutePageRange: ClosedRange? {
- guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
- return firstPage.absolutePageIndex...lastPage.absolutePageIndex
- }
-
- public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
- chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
- }
-
- public func pageNumber(containing absoluteOffset: Int) -> Int? {
- page(containing: absoluteOffset)?.absolutePageIndex
- }
-
- public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
- chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
- }
-
- public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
- guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
- return pages[pageNumber - 1]
- }
-
- public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
- indexTable.anchor(forAbsoluteIndex: index, in: chapter)
- }
-
- public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
- indexTable.anchor(forGlobalIndex: index)
- }
-
- public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
- let start = anchor(forAbsoluteIndex: absoluteRange.location)
- let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
- return RDEPUBTextRangeAnchor(start: start, end: end)
- }
-
- public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
- indexTable.globalRange(for: rangeAnchor)
- }
-
- public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
- guard page(containing: absoluteRange.location) != nil else { return nil }
- let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
- let text = chapter.attributedContent.attributedSubstring(from: absoluteRange).string
- let rangeInfo = RDEPUBTextOffsetRangeInfo(
- href: chapter.href,
- start: absoluteRange.location,
- end: absoluteRange.location + absoluteRange.length
- ).jsonString()
- return RDEPUBSelection(
- bookIdentifier: bookIdentifier,
- location: location,
- text: text,
- rangeInfo: rangeInfo
- )
- }
-
- public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
- indexTable.location(
- for: rangeAnchor(for: absoluteRange),
- in: chapter,
- bookIdentifier: bookIdentifier
- )
- }
-
- public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
- location(for: page.contentRange, bookIdentifier: bookIdentifier)
- }
-
- public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
- guard let range = absoluteRange(for: location) else { return nil }
- return page(containing: range.location)
- }
-
- public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
- guard let range = absoluteRange(for: searchMatch) else { return nil }
- return page(containing: range.location)
- }
-
- public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.chapterRange(for: rangeAnchor)
- }
-
- if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
- let anchor = indexTable.anchor(for: cfi) {
- return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
- }
-
- if let rangeAnchor = location.rangeAnchor {
- return indexTable.chapterRange(for: rangeAnchor)
- }
-
- if let fragment = location.fragment,
- let offset = fragmentOffsets[fragment] {
- return NSRange(location: offset, length: 1)
- }
-
- let lastOffset = max(attributedContent.length - 1, 0)
- let offset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
- return NSRange(location: offset, length: 1)
- }
-
- public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.chapterRange(for: rangeAnchor)
- }
-
- if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
- return endpointRange
- }
-
- if let rangeAnchor = highlight.location.rangeAnchor {
- return indexTable.chapterRange(for: rangeAnchor)
- }
- return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
- }
-
- public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.chapterRange(for: rangeAnchor)
- }
- if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
- let anchor = indexTable.anchor(for: cfi) {
- let location = indexTable.chapterOffset(for: anchor)
- return NSRange(
- location: location,
- length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
- )
- }
- if let location = searchMatch.rangeLocation {
- return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
- }
- if let rangeAnchor = searchMatch.rangeAnchor {
- return indexTable.chapterRange(for: rangeAnchor)
- }
- return nil
- }
-
- public func globalRange(for location: RDEPUBLocation) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.globalRange(for: rangeAnchor)
- }
-
- if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
- let anchor = indexTable.anchor(for: cfi) {
- return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
- }
-
- if let rangeAnchor = location.rangeAnchor {
- return indexTable.globalRange(for: rangeAnchor)
- }
- guard let chapterRange = absoluteRange(for: location),
- let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
- return nil
- }
- return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
- }
-
- public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.globalRange(for: rangeAnchor)
- }
-
- if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
- return endpointRange
- }
-
- if let rangeAnchor = highlight.location.rangeAnchor {
- return indexTable.globalRange(for: rangeAnchor)
- }
- guard let chapterRange = absoluteRange(for: highlight),
- let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
- return nil
- }
- return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
- }
-
- public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return indexTable.globalRange(for: rangeAnchor)
- }
- if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
- let anchor = indexTable.anchor(for: cfi) {
- let chapterLocation = indexTable.chapterOffset(for: anchor)
- return NSRange(
- location: indexTable.globalIndex(for: anchor),
- length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
- )
- }
- if let rangeAnchor = searchMatch.rangeAnchor {
- return indexTable.globalRange(for: rangeAnchor)
- }
- guard let chapterRange = absoluteRange(for: searchMatch),
- let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
- return nil
- }
- return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
- }
-
- public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
- let pageRange = absoluteOffsetRange(for: page)
- return allHighlights.filter { highlight in
- guard highlight.location.href == chapter.href else { return false }
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
- let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
- return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
- }
- if let anchor = highlight.location.rangeAnchor?.start {
- return pageRange.contains(absoluteOffset(for: anchor))
- }
- guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
- return false
- }
- return NSIntersectionRange(range, page.contentRange).length > 0
- }
- }
-
- public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
- return matches.filter { match in
- guard match.href == chapter.href else { return false }
- if let range = absoluteRange(for: match) {
- return NSIntersectionRange(range, page.contentRange).length > 0
- }
- return false
- }
- }
-
- public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
- searchMatches(on: page, from: matches)
- }
-
- public func pageNumber(for location: RDEPUBLocation) -> Int? {
- guard let page = page(for: location) else { return nil }
- return page.absolutePageIndex + 1
- }
-
- public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
- guard let page = page(for: searchMatch) else { return nil }
- return page.absolutePageIndex + 1
- }
-
- public func contains(
- tableOfContentsItem item: EPUBTableOfContentsItem,
- normalizer: (String) -> String?
- ) -> Bool {
- guard let chapterHref = normalizer(href),
- let itemHref = normalizer(item.href.components(separatedBy: "#").first ?? item.href) else {
- return false
- }
- return chapterHref == itemHref
- }
-
- public func tableOfContentsItems(
- from items: [EPUBTableOfContentsItem],
- normalizer: (String) -> String?
- ) -> [EPUBTableOfContentsItem] {
- items.flatMap { item -> [EPUBTableOfContentsItem] in
- let descendants = tableOfContentsItems(from: item.children, normalizer: normalizer)
- return contains(tableOfContentsItem: item, normalizer: normalizer) ? [item] + descendants : descendants
- }
- }
-
- public func primaryTableOfContentsItem(
- from items: [EPUBTableOfContentsItem],
- normalizer: (String) -> String?
- ) -> EPUBTableOfContentsItem? {
- tableOfContentsItems(from: items, normalizer: normalizer).first
- }
-
- public func applyHighlights(
- to content: NSMutableAttributedString,
- page: RDEPUBTextPage,
- highlights: [RDEPUBHighlight]
- ) {
- for highlight in highlights {
- guard highlight.location.href == chapter.href else { continue }
- guard let range = absoluteRange(for: highlight) else { continue }
- let overlap = NSIntersectionRange(range, page.contentRange)
- guard overlap.length > 0 else { continue }
- let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
- guard relativeRange.location >= 0,
- relativeRange.location + relativeRange.length <= content.length else { continue }
-
- switch highlight.style {
- case .highlight:
- content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
- case .underline:
- content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
- }
- }
- }
-
- private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range {
- let lowerBound = page.pageStartOffset
- let upperBound = page.pageEndOffset + 1
- return lowerBound.. Int {
- indexTable.chapterOffset(for: anchor)
- }
-
- private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
- guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
- let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
- let startAnchor = indexTable.anchor(for: startCFI),
- let endAnchor = indexTable.anchor(for: endCFI),
- startAnchor.fileIndex == endAnchor.fileIndex else {
- return nil
- }
- let start = indexTable.chapterOffset(for: startAnchor)
- let end = indexTable.chapterOffset(for: endAnchor)
- return NSRange(location: min(start, end), length: max(abs(end - start), 1))
- }
-
- private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
- guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
- let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
- let startAnchor = indexTable.anchor(for: startCFI),
- let endAnchor = indexTable.anchor(for: endCFI),
- startAnchor.fileIndex == endAnchor.fileIndex else {
- return nil
- }
- let start = indexTable.globalIndex(for: startAnchor)
- let end = indexTable.globalIndex(for: endAnchor)
- return NSRange(location: min(start, end), length: max(abs(end - start), 1))
- }
-
- private func recoveredSearchRangeLength(
- for searchMatch: RDEPUBSearchMatch,
- cfi: RDEPUBCFI,
- startOffset: Int
- ) -> Int {
- let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
- let fallbackLength = max(searchMatch.rangeLength, 1)
- let candidateLength = exactLength > 0 ? exactLength : fallbackLength
- let remainingLength = max(attributedContent.length - startOffset, 1)
- return min(max(candidateLength, 1), remainingLength)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift
deleted file mode 100644
index 28c1e63..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
- public init() {}
-
- public static var isAvailable: Bool {
-#if canImport(DTCoreText)
- return true
-#else
- return false
-#endif
- }
-
- public func renderChapter(
- request: RDEPUBTextChapterRenderRequest
- ) throws -> RDEPUBRenderedChapterContent {
-#if canImport(DTCoreText)
- let chapterContext = request.context
- guard let data = chapterContext.html.data(using: .utf8) else {
- throw RDEPUBTextRenderingError.htmlEncodingFailed
- }
-
- guard let rendered = makeAttributedString(from: data, request: request) else {
- return fallbackRenderedContent(request: request)
- }
-
- let attributedString = NSMutableAttributedString(attributedString: rendered)
- RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
- let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
- RDEPUBTextRendererSupport.normalizeReadingAttributes(
- in: attributedString,
- style: request.style,
- layoutConfig: request.layoutConfig ?? .default
- )
- return RDEPUBRenderedChapterContent(
- attributedString: attributedString,
- fragmentOffsets: fragmentOffsets,
- resourceDiagnostics: chapterContext.resourceDiagnostics
- )
-#else
- return fallbackRenderedContent(request: request)
-#endif
- }
-
- public func renderChapter(
- html: String,
- baseURL: URL?,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBRenderedChapterContent {
- let request = RDEPUBTextTypesetterPipeline().makeRequest(
- from: RDEPUBTypesettingInput(
- href: "",
- spineIndex: nil,
- title: "",
- rawHTML: html,
- baseURL: baseURL,
- style: style,
- resourceResolver: nil
- )
- ).request
- return try renderChapter(request: request)
- }
-
- private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
- let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
- RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
- let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
- RDEPUBTextRendererSupport.normalizeReadingAttributes(
- in: attributedString,
- style: request.style,
- layoutConfig: request.layoutConfig ?? .default
- )
- return RDEPUBRenderedChapterContent(
- attributedString: attributedString,
- fragmentOffsets: fragmentOffsets,
- resourceDiagnostics: request.context.resourceDiagnostics
- )
- }
-
-#if canImport(DTCoreText)
-
- private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
- let builder = DTHTMLAttributedStringBuilder(
- html: data,
- options: dtOptions(request: request),
- documentAttributes: nil
- )
- builder?.willFlushCallback = { element in
- guard let element else { return }
- RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering(
- element,
- style: request.style,
- maxImageSize: resolvedMaxImageSize(for: request)
- )
- }
- return builder?.generatedAttributedString()
- }
-
- private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
- let style = request.style
- let maxImageSize = resolvedMaxImageSize(for: request)
- var options: [AnyHashable: Any] = [
- NSTextSizeMultiplierDocumentOption: 1.0,
- DTDefaultFontFamily: style.font.familyName,
- DTDefaultFontName: style.font.fontName,
- DTDefaultFontSize: style.font.pointSize,
- DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
- DTUseiOS6Attributes: true,
- DTMaxImageSize: NSValue(cgSize: maxImageSize)
- ]
-
- if let baseURL = request.context.baseURL {
- options[NSBaseURLDocumentOption] = baseURL
- }
- if let textColor = style.textColor {
- options[DTDefaultTextColor] = textColor
- }
-
- return options
- }
-
- private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
- let layoutConfig = request.layoutConfig ?? .default
- let fallbackPageSize = request.pageSize ?? layoutConfig.fallbackViewportSize
- let contentRect = layoutConfig.contentRect(fallback: fallbackPageSize)
- let maxWidth = max(round(contentRect.width), 1)
- let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
- return CGSize(width: maxWidth, height: maxHeight)
- }
-#endif
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
deleted file mode 100644
index 5d01418..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
+++ /dev/null
@@ -1,542 +0,0 @@
-
-import Foundation
-
-public struct RDEPUBRowColumnIndex: Codable, Equatable {
-
- public let row: Int
-
- public let startOffset: Int
-
- public let endOffset: Int
-
- public init(row: Int, startOffset: Int, endOffset: Int) {
- self.row = row
- self.startOffset = startOffset
- self.endOffset = endOffset
- }
-
- public func contains(_ offset: Int) -> Bool {
- offset >= startOffset && offset <= endOffset
- }
-}
-
-public struct RDEPUBTextIndexTable {
-
- public let chapterStartOffsets: [Int]
-
- public let chapterLengths: [Int]
-
- public let hrefToChapterIndex: [String: Int]
-
- public let hrefToFileIndex: [String: Int]
-
- public let fragmentOffsetsByHref: [String: [String: Int]]
-
- public let fileIndexToHref: [Int: String]
-
- public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
-
- public let fileTextMap: [Int: String]
-
- public let fileCFIMap: [Int: RDEPUBCFIMap]
-
- public var totalCharacterCount: Int {
- guard let lastIndex = chapterStartOffsets.indices.last,
- chapterLengths.indices.contains(lastIndex) else {
- return 0
- }
- return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
- }
-
- public init(chapters: [RDEPUBTextChapter]) {
- var offsets: [Int] = []
- var lengths: [Int] = []
- var hrefMap: [String: Int] = [:]
- var hrefToFileMap: [String: Int] = [:]
- var fragmentMap: [String: [String: Int]] = [:]
- var fileIndexMap: [Int: String] = [:]
- var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
- var textMap: [Int: String] = [:]
- var cfiMap: [Int: RDEPUBCFIMap] = [:]
- var running = 0
-
- for (index, chapter) in chapters.enumerated() {
- hrefMap[chapter.href] = index
- hrefToFileMap[chapter.href] = chapter.spineIndex
- offsets.append(running)
- lengths.append(chapter.attributedContent.length)
- running += chapter.attributedContent.length
- fragmentMap[chapter.href] = chapter.fragmentOffsets
- fileIndexMap[chapter.spineIndex] = chapter.href
- let chapterText = chapter.attributedContent.string
- rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText)
- textMap[chapter.spineIndex] = chapterText
- if let chapterCFIMap = chapter.cfiMap {
- cfiMap[chapter.spineIndex] = chapterCFIMap
- }
- }
-
- self.chapterStartOffsets = offsets
- self.chapterLengths = lengths
- self.hrefToChapterIndex = hrefMap
- self.hrefToFileIndex = hrefToFileMap
- self.fragmentOffsetsByHref = fragmentMap
- self.fileIndexToHref = fileIndexMap
- self.fileRowColumnMap = rowColumnMap
- self.fileTextMap = textMap
- self.fileCFIMap = cfiMap
- }
-
- public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
- let normalizedIndex = clampedOffset(index, in: chapter)
- let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
- let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
- let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
- return RDEPUBTextAnchor(
- fileIndex: chapter.spineIndex,
- row: row,
- column: column,
- chapterOffset: normalizedIndex,
- fragmentID: fragmentID
- )
- }
-
- public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
- if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
- let anchor = anchor(for: cfiRange.start) {
- return anchor
- }
-
- if let anchor = location.rangeAnchor?.start {
- return anchor
- }
-
- if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
- let anchor = anchor(for: cfi) {
- return anchor
- }
-
- guard let chapterIndex = hrefToChapterIndex[location.href],
- let fileIndex = hrefToFileIndex[location.href] else { return nil }
- let fragments = fragmentOffsetsByHref[location.href] ?? [:]
- let chapterOffset: Int
-
- if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
- chapterOffset = fragmentOffset
- } else {
- let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
- let lastOffset = max(estimatedLength - 1, 0)
- chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
- }
-
- return RDEPUBTextAnchor(
- fileIndex: fileIndex,
- row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- chapterOffset: chapterOffset,
- fragmentID: location.fragment
- )
- }
-
- public func anchor(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? {
- let resolved = RDEPUBCFIResolver.resolve(cfi)
- let fileIndex: Int
- if let resolvedFileIndex = resolved.fileIndex {
- fileIndex = resolvedFileIndex
- } else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] {
- fileIndex = mappedFileIndex
- } else {
- return nil
- }
-
- guard let href = href(for: fileIndex) ?? resolved.href else { return nil }
- let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1)
- let lastOffset = max(chapterLength - 1, 0)
- let recovery = RDEPUBCFIRecoveryEngine.recover(
- cfi: cfi,
- cfiMap: fileCFIMap[fileIndex],
- chapterText: fileTextMap[fileIndex],
- fragmentOffsets: fragmentOffsetsByHref[href] ?? [:],
- fallbackOffset: resolved.chapterOffset,
- lastOffset: lastOffset
- )
- let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset)
- let fragmentID = resolved.fragmentID ?? nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
-
- return RDEPUBTextAnchor(
- fileIndex: fileIndex,
- row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- chapterOffset: chapterOffset,
- fragmentID: fragmentID
- )
- }
-
- public func rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? {
- guard let start = anchor(for: cfiRange.start),
- let end = anchor(for: cfiRange.end),
- start.fileIndex == end.fileIndex else {
- return nil
- }
- return RDEPUBTextRangeAnchor(start: start, end: end)
- }
-
- public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? {
- guard let href = href(for: anchor.fileIndex) else { return nil }
- let chapterOffset = chapterOffset(for: anchor)
- if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) {
- let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0)
- return RDEPUBCFIGenerator.makeCFI(
- href: href,
- fileIndex: anchor.fileIndex,
- contentPath: marker.cfiPath,
- characterOffset: localOffset,
- textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
- )
- }
- return RDEPUBCFIGenerator.makeOffsetCFI(
- href: href,
- fileIndex: anchor.fileIndex,
- chapterOffset: chapterOffset,
- fragmentID: anchor.fragmentID,
- textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
- )
- }
-
- public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? {
- guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex,
- let href = href(for: rangeAnchor.start.fileIndex) else {
- return nil
- }
- let startOffset = chapterOffset(for: rangeAnchor.start)
- let endOffset = chapterOffset(for: rangeAnchor.end)
- let startCFI = cfi(for: rangeAnchor.start)
- let endCFI = cfi(for: rangeAnchor.end)
- if let startCFI, let endCFI {
- let parent = RDEPUBCFI(
- packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath),
- contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath)
- )
- return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI)
- }
- return RDEPUBCFIGenerator.makeOffsetRangeCFI(
- href: href,
- fileIndex: rangeAnchor.start.fileIndex,
- startOffset: startOffset,
- endOffset: endOffset,
- fragmentID: rangeAnchor.start.fragmentID,
- startTextAssertion: textAssertion(
- fileIndex: rangeAnchor.start.fileIndex,
- offset: startOffset
- ),
- endTextAssertion: textAssertion(
- fileIndex: rangeAnchor.end.fileIndex,
- offset: endOffset
- )
- )
- }
-
- public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
- guard let fileIndex = fileIndex(forCharacterPosition: index),
- let href = href(for: fileIndex) else {
- return nil
- }
-
- let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
- let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
- return RDEPUBTextAnchor(
- fileIndex: fileIndex,
- row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
- chapterOffset: chapterOffset,
- fragmentID: fragmentID
- )
- }
-
- public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
- guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
- let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
- let resolvedOffset = chapterOffset(for: anchor)
- return chapter.pages.firstIndex { page in
- NSLocationInRange(resolvedOffset, page.contentRange)
- }.map { $0 + basePageIndex }
- }
-
- public func href(for fileIndex: Int) -> String? {
- fileIndexToHref[fileIndex]
- }
-
- public func chapterIndex(for href: String) -> Int? {
- hrefToChapterIndex[href]
- }
-
- public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
- guard let href = href(for: fileIndex),
- let chapterIndex = hrefToChapterIndex[href],
- chapterStartOffsets.indices.contains(chapterIndex) else {
- return nil
- }
- return chapterStartOffsets[chapterIndex]
- }
-
- public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
- guard let href = href(for: fileIndex),
- let chapterIndex = hrefToChapterIndex[href],
- chapterLengths.indices.contains(chapterIndex) else {
- return nil
- }
- return chapterLengths[chapterIndex]
- }
-
- public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
- anchor.chapterOffset
- }
-
- public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
- let chapterOffset = chapterOffset(for: anchor)
- let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
- let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
- let lastOffset = max(chapterLength - 1, 0)
- return baseOffset + min(max(chapterOffset, 0), lastOffset)
- }
-
- public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
- globalIndex(for: anchor)
- }
-
- public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
- let start = chapterOffset(for: rangeAnchor.start)
- let end = max(start, chapterOffset(for: rangeAnchor.end))
- return NSRange(location: start, length: max(end - start, 0))
- }
-
- public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
- let start = globalIndex(for: rangeAnchor.start)
- let end = max(start, globalIndex(for: rangeAnchor.end))
- return NSRange(location: start, length: max(end - start, 0))
- }
-
- public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
- globalRange(for: rangeAnchor)
- }
-
- public func location(
- for anchor: RDEPUBTextAnchor,
- in chapter: RDEPUBTextChapter,
- bookIdentifier: String?
- ) -> RDEPUBLocation {
- let chapterOffset = self.chapterOffset(for: anchor)
- let totalLength = max(chapter.attributedContent.length - 1, 1)
- let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
- return RDEPUBLocation(
- bookIdentifier: bookIdentifier,
- href: chapter.href,
- progression: progression,
- lastProgression: progression,
- fragment: anchor.fragmentID,
- rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor),
- cfi: cfi(for: anchor)?.rawValue,
- lastCFI: cfi(for: anchor)?.rawValue
- )
- }
-
- public func location(
- for rangeAnchor: RDEPUBTextRangeAnchor,
- in chapter: RDEPUBTextChapter,
- bookIdentifier: String?
- ) -> RDEPUBLocation {
- let start = chapterOffset(for: rangeAnchor.start)
- let end = max(start, chapterOffset(for: rangeAnchor.end))
- let totalLength = max(chapter.attributedContent.length - 1, 1)
- let clampedStart = min(max(start, 0), totalLength)
- let clampedEnd = min(max(end, clampedStart), totalLength)
- return RDEPUBLocation(
- bookIdentifier: bookIdentifier,
- href: chapter.href,
- progression: Double(clampedStart) / Double(totalLength),
- lastProgression: Double(clampedEnd) / Double(totalLength),
- fragment: rangeAnchor.start.fragmentID,
- rangeAnchor: rangeAnchor,
- cfi: cfi(for: rangeAnchor.start)?.rawValue,
- lastCFI: cfi(for: rangeAnchor.end)?.rawValue,
- rangeCFI: cfiRange(for: rangeAnchor)?.rawValue
- )
- }
-
- public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
- guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
- if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
- return rowIndex
- }
- return rows.last?.row ?? 0
- }
-
- public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
- guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
- if let rowEntry = rows.first(where: { $0.contains(index) }) {
- return max(index - rowEntry.startOffset, 0)
- }
- guard let lastRow = rows.last else { return 0 }
- return max(index - lastRow.startOffset, 0)
- }
-
- public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
- guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
- let normalizedRow = min(max(row, 0), rows.count - 1)
- let rowEntry = rows[normalizedRow]
- let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
- return rowEntry.startOffset + min(max(column, 0), maxColumn)
- }
-
- public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
- guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
- let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
- return nil
- }
- return baseOffset + chapterOffset
- }
-
- public func fileIndex(forCharacterPosition index: Int) -> Int? {
- guard totalCharacterCount > 0 else { return nil }
- let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
- for (href, chapterIndex) in hrefToChapterIndex {
- guard chapterStartOffsets.indices.contains(chapterIndex),
- chapterLengths.indices.contains(chapterIndex),
- let fileIndex = hrefToFileIndex[href] else {
- continue
- }
-
- let start = chapterStartOffsets[chapterIndex]
- let length = chapterLengths[chapterIndex]
- let endExclusive = start + max(length, 1)
- if normalized >= start && normalized < endExclusive {
- return fileIndex
- }
- }
- return fileIndexToHref.keys.sorted().last
- }
-
- public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
- guard let start = chapterStartOffset(forFileIndex: fileIndex),
- let chapterLength = chapterLength(forFileIndex: fileIndex) else {
- return nil
- }
- let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
- return normalized - start
- }
-
- private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
- let lastOffset = max(chapter.attributedContent.length - 1, 0)
- return min(max(index, 0), lastOffset)
- }
-
- private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
- var bestID: String?
- var bestOffset = -1
-
- for (id, fragOffset) in chapter.fragmentOffsets {
- if fragOffset <= offset && fragOffset > bestOffset {
- bestOffset = fragOffset
- bestID = id
- }
- }
-
- return bestID
- }
-
- private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
- guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
- return nil
- }
-
- var bestID: String?
- var bestOffset = -1
- for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
- bestOffset = fragmentOffset
- bestID = id
- }
- return bestID
- }
-
- private func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? {
- guard let markers = fileCFIMap[fileIndex]?.markers else { return nil }
- return markers
- .filter {
- guard let start = $0.chapterOffset,
- let length = $0.textNodeLength,
- length > 0 else {
- return false
- }
- return chapterOffset >= start && chapterOffset < start + length
- }
- .max { lhs, rhs in
- let leftStart = lhs.chapterOffset ?? 0
- let rightStart = rhs.chapterOffset ?? 0
- if leftStart != rightStart {
- return leftStart < rightStart
- }
- return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0)
- }
- }
-
- private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? {
- guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil }
- let nsText = text as NSString
- let length = nsText.length
- guard length > 0 else { return nil }
-
- let location = min(max(offset, 0), max(length - 1, 0))
- let exactLength = min(16, max(length - location, 0))
- guard exactLength > 0 else { return nil }
-
- let prefixStart = max(location - 8, 0)
- let suffixStart = min(location + exactLength, length)
- return RDEPUBCFITextAssertion(
- prefix: prefixStart < location
- ? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart))
- : nil,
- exact: nsText.substring(with: NSRange(location: location, length: exactLength)),
- suffix: suffixStart < length
- ? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart)))
- : nil
- )
- }
-
- private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
- let nsText = text as NSString
- let length = nsText.length
- guard length > 0 else {
- return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
- }
-
- var rows: [RDEPUBRowColumnIndex] = []
- var rowNumber = 0
- var lineStart = 0
-
- nsText.enumerateSubstrings(
- in: NSRange(location: 0, length: length),
- options: [.byLines, .substringNotRequired]
- ) { _, substringRange, enclosingRange, _ in
- let startOffset = enclosingRange.location
- let lineLength = max(substringRange.length, 0)
- let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
- rows.append(
- RDEPUBRowColumnIndex(
- row: rowNumber,
- startOffset: startOffset,
- endOffset: min(endOffset, max(length - 1, 0))
- )
- )
- rowNumber += 1
- lineStart = enclosingRange.location + enclosingRange.length
- }
-
- if rows.isEmpty {
- rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
- } else if lineStart == length, text.hasSuffix("\n") {
- rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
- }
-
- return rows
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift
deleted file mode 100644
index 14bcab9..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-
-import Foundation
-
-public struct RDEPUBTextPositionConverter {
-
- public let book: RDEPUBTextBook
-
- public init(book: RDEPUBTextBook) {
- self.book = book
- }
-
- public var totalCharacterCount: Int {
- book.indexTable.totalCharacterCount
- }
-
- public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
- book.indexTable.globalIndex(for: anchor)
- }
-
- public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
- book.indexTable.globalRange(for: rangeAnchor)
- }
-
- public func fileIndex(forCharacterPosition index: Int) -> Int? {
- book.indexTable.fileIndex(forCharacterPosition: index)
- }
-
- public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
- book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
- }
-
- public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
- book.indexTable.anchor(forGlobalIndex: index)
- }
-
- public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
- book.indexTable.anchor(for: location)
- }
-
- public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
- book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
- }
-
- public func pageNumber(forCharacterPosition index: Int) -> Int? {
- guard let anchor = anchor(forCharacterPosition: index) else {
- return nil
- }
- return pageNumber(for: anchor)
- }
-
- public func location(
- for anchor: RDEPUBTextAnchor,
- bookIdentifier: String?
- ) -> RDEPUBLocation? {
- guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else {
- return nil
- }
- return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
- }
-
- public func location(
- for rangeAnchor: RDEPUBTextRangeAnchor,
- bookIdentifier: String?
- ) -> RDEPUBLocation? {
- guard let chapter = book.chapters.first(where: { $0.spineIndex == rangeAnchor.start.fileIndex }) else {
- return nil
- }
- return book.indexTable.location(for: rangeAnchor, in: chapter, bookIdentifier: bookIdentifier)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
deleted file mode 100644
index 7ae82d3..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
+++ /dev/null
@@ -1,337 +0,0 @@
-import UIKit
-
-public extension NSAttributedString.Key {
-
- static let rdPageBlockRange = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockRange")
-
- static let rdPageBlockIndex = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockIndex")
-
- static let rdPageFragmentID = NSAttributedString.Key("com.RDEpubReader.epub.pageFragmentID")
-
- static let rdPageAttachmentKind = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentKind")
-
- static let rdPageBlockKind = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockKind")
-
- static let rdPageSemanticHints = NSAttributedString.Key("com.RDEpubReader.epub.pageSemanticHints")
-
- static let rdPageAttachmentPlacement = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentPlacement")
-}
-
-public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
- case paragraph
- case list
- case table
- case code
- case blockquote
- case attachment
- case generic
-}
-
-public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
- case avoidPageBreakInside
- case keepWithNext
- case pageBreakBefore
- case pageBreakAfter
- case pageRelate
-}
-
-public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
- case inline
- case baseline
- case centered
-}
-
-public struct RDEPUBTextRenderStyle {
-
- public var font: UIFont
-
- public var lineSpacing: CGFloat
-
- public var textColor: UIColor?
-
- public var backgroundColor: UIColor?
-
- public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
- self.font = font
- self.lineSpacing = lineSpacing
- self.textColor = textColor
- self.backgroundColor = backgroundColor
- }
-}
-
-public struct RDEPUBTextLayoutConfig: Equatable {
-
- public var frameWidth: CGFloat
-
- public var frameHeight: CGFloat
-
- public var edgeInsets: UIEdgeInsets
-
- public var numberOfColumns: Int
-
- public var columnGap: CGFloat
-
- public var avoidOrphans: Bool
-
- public var avoidWidows: Bool
-
- public var avoidPageBreakInsideEnabled: Bool
-
- public var hyphenation: Bool
-
- public var imageMaxHeightRatio: CGFloat
-
- public var fallbackViewportSize: CGSize
-
- public init(
- frameWidth: CGFloat = 0,
- frameHeight: CGFloat = 0,
- edgeInsets: UIEdgeInsets = .zero,
- numberOfColumns: Int = 1,
- columnGap: CGFloat = 20,
- avoidOrphans: Bool = true,
- avoidWidows: Bool = true,
- avoidPageBreakInsideEnabled: Bool = true,
- hyphenation: Bool = true,
- imageMaxHeightRatio: CGFloat = 0.85,
- fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
- ) {
- self.frameWidth = frameWidth
- self.frameHeight = frameHeight
- self.edgeInsets = edgeInsets
- self.numberOfColumns = max(1, numberOfColumns)
- self.columnGap = max(0, columnGap)
- self.avoidOrphans = avoidOrphans
- self.avoidWidows = avoidWidows
- self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
- self.hyphenation = hyphenation
- self.imageMaxHeightRatio = imageMaxHeightRatio
- self.fallbackViewportSize = fallbackViewportSize
- }
-
- public static let `default` = RDEPUBTextLayoutConfig()
-
- public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
- CGSize(
- width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
- height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
- )
- }
-
- public func contentRect(fallback pageSize: CGSize) -> CGRect {
- let size = resolvedFrameSize(fallback: pageSize)
- return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
- }
-
- public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
- let rect = contentRect(fallback: pageSize)
- let columns = max(1, numberOfColumns)
- guard columns > 1 else { return [rect] }
-
- let totalGap = CGFloat(columns - 1) * columnGap
- let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
-
- return (0.. RDEPUBRenderedChapterContent
-
- func renderChapter(
- html: String,
- baseURL: URL?,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBRenderedChapterContent
-}
-
-public extension RDEPUBTextRenderer {
- func renderChapter(
- html: String,
- baseURL: URL?,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBRenderedChapterContent {
- let context = RDEPUBTextChapterContext(
- href: "",
- title: "",
- html: html,
- baseURL: baseURL,
- stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
- resourceDiagnostics: []
- )
- return try renderChapter(request: RDEPUBTextChapterRenderRequest(context: context, style: style))
- }
-}
-
-public enum RDEPUBTextRenderingError: LocalizedError {
- case htmlEncodingFailed
- case htmlImportFailed
-
- public var errorDescription: String? {
- switch self {
- case .htmlEncodingFailed:
- return "HTML 编码失败"
- case .htmlImportFailed:
- return "HTML 富文本导入失败"
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
deleted file mode 100644
index 7af5b10..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
+++ /dev/null
@@ -1,128 +0,0 @@
-import Foundation
-
-final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
- private let textBook: RDEPUBTextBook
- private let publication: RDEPUBPublication
-
- init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) {
- self.textBook = textBook
- self.publication = publication
- }
-
- func search(keyword: String) -> [RDEPUBSearchMatch] {
- let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedKeyword.isEmpty else {
- return []
- }
-
- var matches: [RDEPUBSearchMatch] = []
- for chapter in textBook.chapters {
- guard let chapterData = textBook.chapterData(for: chapter.href) else { continue }
- let source = chapter.attributedContent.string as NSString
- let fullLength = source.length
- guard fullLength > 0 else {
- continue
- }
-
- let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
- var localMatchIndex = 0
- var searchRange = NSRange(location: 0, length: fullLength)
-
- while searchRange.length > 0 {
- let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else {
- break
- }
-
- let progressionDenominator = max(fullLength - 1, 1)
- let progression = Double(foundRange.location) / Double(progressionDenominator)
- let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
- matches.append(
- RDEPUBSearchMatch(
- href: normalizedHref,
- progression: progression,
- previewText: previewText(in: source, matchRange: foundRange),
- localMatchIndex: localMatchIndex,
- rangeLocation: foundRange.location,
- rangeLength: foundRange.length,
- rangeAnchor: rangeAnchor,
- cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
- rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
- )
- )
-
- localMatchIndex += 1
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- if nextLocation >= fullLength {
- break
- }
- searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
- }
- }
-
- return matches
- }
-
- private func previewText(in text: NSString, matchRange: NSRange) -> String {
- let previewRadius = 12
- let start = max(matchRange.location - previewRadius, 0)
- let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
- let range = NSRange(location: start, length: max(end - start, 0))
- return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
- let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedKeyword.isEmpty else { return [] }
-
- var matches: [RDEPUBSearchMatch] = []
- for chapter in textBook.chapters {
- let chapterData = RDEPUBChapterData(
- chapter: chapter,
- indexTable: RDEPUBTextIndexTable(chapters: [chapter])
- )
- let source = chapter.attributedContent.string as NSString
- let fullLength = source.length
- guard fullLength > 0 else { continue }
-
- var localMatchIndex = 0
- var searchRange = NSRange(location: 0, length: fullLength)
-
- while searchRange.length > 0 {
- let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else { break }
-
- let progressionDenominator = max(fullLength - 1, 1)
- let progression = Double(foundRange.location) / Double(progressionDenominator)
- let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
- let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
- let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
- let previewRadius = 12
- let previewStart = max(foundRange.location - previewRadius, 0)
- let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
- let previewRange = NSRange(location: previewStart, length: max(previewEnd - previewStart, 0))
- let previewText = source.substring(with: previewRange).trimmingCharacters(in: .whitespacesAndNewlines)
-
- matches.append(
- RDEPUBSearchMatch(
- href: chapter.href,
- progression: progression,
- previewText: previewText,
- localMatchIndex: localMatchIndex,
- rangeLocation: foundRange.location,
- rangeLength: foundRange.length,
- rangeAnchor: rangeAnchor,
- cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
- rangeCFI: cfiRange?.rawValue
- )
- )
-
- localMatchIndex += 1
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- if nextLocation >= fullLength { break }
- searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
- }
- }
- return matches
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift
deleted file mode 100644
index e0cdc60..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift
+++ /dev/null
@@ -1,158 +0,0 @@
-import UIKit
-
-public final class RDEpubPlainTextBookBuilder {
- private let renderer: RDEPUBTextRenderer
- private let layoutConfig: RDEPUBTextLayoutConfig
-
- public init(
- renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
- layoutConfig: RDEPUBTextLayoutConfig = .default
- ) {
- self.renderer = renderer
- self.layoutConfig = layoutConfig
- }
-
- public func build(
- textFileURL: URL,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle
- ) throws -> RDEPUBTextBook {
- let rawText = rd_decodeTextFile(url: textFileURL)
- let chapterSpecs = splitChapters(from: rawText)
-
- var chapters: [RDEPUBTextChapter] = []
- var flatPages: [RDEPUBTextPage] = []
-
- for (index, spec) in chapterSpecs.enumerated() {
- let html = wrapTextAsHTML(spec.content)
- let request = RDEPUBTextChapterRenderRequest(
- context: RDEPUBTextChapterContext(
- href: "chapter_\(index).xhtml",
- title: spec.title ?? "第 \(index + 1) 章",
- html: html,
- baseURL: nil,
- stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
- resourceDiagnostics: []
- ),
- style: style,
- pageSize: pageSize,
- layoutConfig: layoutConfig
- )
- let rendered = try renderer.renderChapter(request: request)
-
- let content = NSMutableAttributedString(attributedString: rendered.attributedString)
- let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
- let effectiveFrames = layoutFrames.isEmpty && content.length > 0
- ? [
- RDEPUBTextLayoutFrame(
- contentRange: NSRange(location: 0, length: content.length),
- breakReason: .chapterEnd,
- blockRange: nil,
- attachmentRanges: [],
- attachmentKinds: [],
- blockKinds: [],
- semanticHints: [],
- attachmentPlacements: [],
- trailingFragmentID: nil,
- diagnostics: [
- "page break: chapterEnd",
- "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
- ]
- )
- ]
- : layoutFrames
-
- let href = "chapter_\(index).xhtml"
- let chapterAttributedContent = content.copy() as! NSAttributedString
- let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
- let range = frame.contentRange
- return RDEPUBTextPage(
- absolutePageIndex: flatPages.count + localPageIndex,
- chapterIndex: index,
- spineIndex: index,
- href: href,
- chapterTitle: spec.title ?? "第 \(index + 1) 章",
- pageIndexInChapter: localPageIndex,
- totalPagesInChapter: effectiveFrames.count,
- chapterContent: chapterAttributedContent,
- contentRange: range,
- pageStartOffset: range.location,
- pageEndOffset: range.location + max(range.length - 1, 0),
- metadata: frame.metadata
- )
- }
-
- chapters.append(
- RDEPUBTextChapter(
- chapterIndex: index,
- spineIndex: index,
- href: href,
- title: spec.title ?? "第 \(index + 1) 章",
- attributedContent: chapterAttributedContent,
- fragmentOffsets: [:],
- cfiMap: nil,
- pageBreakReasons: pages.map { $0.metadata.breakReason },
- pages: pages
- )
- )
- flatPages.append(contentsOf: pages)
- }
-
- return RDEPUBTextBook(chapters: chapters, pages: flatPages)
- }
-
- private struct ChapterSpec {
- let title: String?
- let content: String
- }
-
- private func splitChapters(from text: String) -> [ChapterSpec] {
- let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
- guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
- return [ChapterSpec(title: nil, content: text)]
- }
-
- let nsText = text as NSString
- let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
- guard !matches.isEmpty else {
- return [ChapterSpec(title: nil, content: text)]
- }
-
- var specs: [ChapterSpec] = []
- for (i, match) in matches.enumerated() {
- let titleRange = match.range(at: 1)
- let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
- let contentStart = titleRange.location + titleRange.length
- let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
- let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
- let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
- if !content.isEmpty {
- specs.append(ChapterSpec(title: title, content: content))
- }
- }
-
- if specs.isEmpty {
- return [ChapterSpec(title: nil, content: text)]
- }
- return specs
- }
-
- private func wrapTextAsHTML(_ text: String) -> String {
- let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
- let body = paragraphs.map { "\($0)
" }.joined(separator: "\n")
- return "\(body)"
- }
-
- private func rd_decodeTextFile(url: URL) -> String {
- if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
- return content
- }
- if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
- return content
- }
- if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
- return content
- }
- return ""
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift
deleted file mode 100644
index 66f0799..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift
+++ /dev/null
@@ -1,193 +0,0 @@
-import Foundation
-
-struct RDEPUBCSSCompatibilityLayer {
-
- var policy = RDEPUBStyleCompatibilityPolicy()
-
- func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult {
- var rewritten = css
- var normalized: [String] = []
- var unsupported: [String] = []
-
- if policy.normalizeLineHeight {
- let result = clampNumericProperty(
- in: rewritten,
- property: "line-height",
- minValue: 0.9,
- maxValue: 2.4
- )
- rewritten = result.css
- normalized += result.normalizedRules
- }
-
- if policy.normalizeTextIndent {
- let result = clampLengthProperty(
- in: rewritten,
- property: "text-indent",
- maxAbsolutePX: 64
- )
- rewritten = result.css
- normalized += result.normalizedRules
- }
-
- let alignmentIndentResult = addTextIndentResetForAlignedBlocks(in: rewritten)
- rewritten = alignmentIndentResult.css
- normalized += alignmentIndentResult.normalizedRules
-
- if !policy.allowPublisherMargins {
- let result = clampLengthProperty(
- in: rewritten,
- property: "margin",
- maxAbsolutePX: 64
- )
- rewritten = result.css
- normalized += result.normalizedRules
- }
-
- if policy.fallbackUnsupportedWritingModes,
- rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil {
- unsupported.append("writing-mode")
- rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }"
- normalized.append("writing-mode")
- }
-
- if policy.clampImagesToViewport {
- rewritten += """
-
- img, svg, table, video, canvas {
- max-width: 100% !important;
- height: auto !important;
- box-sizing: border-box !important;
- }
- table {
- overflow-wrap: anywhere;
- border-collapse: collapse;
- }
- """
- normalized.append("media-size-clamp")
- }
-
- return RDEPUBCSSCompatibilityResult(
- css: rewritten,
- report: RDEPUBCSSCompatibilityReport(
- unsupportedRules: unsupported,
- normalizedRules: normalized,
- fontFailures: []
- )
- )
- }
-
- private func clampNumericProperty(
- in css: String,
- property: String,
- minValue: Double,
- maxValue: Double
- ) -> (css: String, normalizedRules: [String]) {
- guard let regex = try? NSRegularExpression(
- pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"#
- ) else {
- return (css, [])
- }
-
- let nsCSS = css as NSString
- var rewritten = css
- var normalized: [String] = []
-
- for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
- guard match.numberOfRanges > 1 else { continue }
- let rawValue = nsCSS.substring(with: match.range(at: 1))
-
- guard let value = Double(rawValue), value < minValue || value > maxValue else { continue }
-
- let clamped = min(max(value, minValue), maxValue)
- if let range = Range(match.range, in: rewritten) {
- rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));")
- normalized.append(property)
- }
- }
- return (rewritten, normalized)
- }
-
- private func clampLengthProperty(
- in css: String,
- property: String,
- maxAbsolutePX: Double
- ) -> (css: String, normalizedRules: [String]) {
- guard let regex = try? NSRegularExpression(
- pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"#
- ) else {
- return (css, [])
- }
-
- let nsCSS = css as NSString
- var rewritten = css
- var normalized: [String] = []
-
- for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
- guard match.numberOfRanges > 1 else { continue }
- let rawValue = nsCSS.substring(with: match.range(at: 1))
-
- guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue }
-
- let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX
- if let range = Range(match.range, in: rewritten) {
- rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;")
- normalized.append(property)
- }
- }
- return (rewritten, normalized)
- }
-
- private func addTextIndentResetForAlignedBlocks(
- in css: String
- ) -> (css: String, normalizedRules: [String]) {
- guard let regex = try? NSRegularExpression(
- pattern: #"(?is)([^{}]+)\{([^{}]*)\}"#
- ) else {
- return (css, [])
- }
-
- let nsCSS = css as NSString
- var rewritten = css
- var normalized: [String] = []
-
- for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
- guard match.numberOfRanges > 2 else { continue }
- let selector = nsCSS.substring(with: match.range(at: 1))
- let declarations = nsCSS.substring(with: match.range(at: 2))
-
- guard declaresRightOrCenterAlignment(declarations),
- !declaresProperty("text-indent", in: declarations) else {
- continue
- }
-
- let replacement = "\(selector){\(declarations)\n text-indent: 0 !important;\n}"
- if let range = Range(match.range, in: rewritten) {
- rewritten.replaceSubrange(range, with: replacement)
- normalized.append("aligned-text-indent-reset")
- }
- }
-
- return (rewritten, normalized)
- }
-
- private func declaresRightOrCenterAlignment(_ declarations: String) -> Bool {
- guard let regex = try? NSRegularExpression(
- pattern: #"(?i)\btext-align\s*:\s*(right|center)\b"#
- ) else {
- return false
- }
- let range = NSRange(location: 0, length: (declarations as NSString).length)
- return regex.firstMatch(in: declarations, range: range) != nil
- }
-
- private func declaresProperty(_ property: String, in declarations: String) -> Bool {
- guard let regex = try? NSRegularExpression(
- pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:"#
- ) else {
- return false
- }
- let range = NSRange(location: 0, length: (declarations as NSString).length)
- return regex.firstMatch(in: declarations, range: range) != nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift
deleted file mode 100644
index 6ee3e85..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-import UIKit
-
-struct RDEPUBFontFallbackResolver {
-
- static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain {
-
- let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"]
-
- let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"]
- return RDEPUBFontFallbackChain(
- requestedFamily: requestedFamily,
- embeddedFamily: embeddedFamily,
- systemFallbacks: preferredCJK + preferredLatin,
- finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName
- )
- }
-
- static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
- RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift
deleted file mode 100644
index a06967e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift
+++ /dev/null
@@ -1,101 +0,0 @@
-import Foundation
-
-public struct RDEPUBStyleCompatibilityPolicy: Equatable {
-
- public var allowPublisherFonts: Bool
-
- public var allowPublisherMargins: Bool
-
- public var normalizeLineHeight: Bool
-
- public var normalizeTextIndent: Bool
-
- public var clampImagesToViewport: Bool
-
- public var fallbackUnsupportedWritingModes: Bool
-
- public init(
- allowPublisherFonts: Bool = true,
- allowPublisherMargins: Bool = true,
- normalizeLineHeight: Bool = true,
- normalizeTextIndent: Bool = true,
- clampImagesToViewport: Bool = true,
- fallbackUnsupportedWritingModes: Bool = true
- ) {
- self.allowPublisherFonts = allowPublisherFonts
- self.allowPublisherMargins = allowPublisherMargins
- self.normalizeLineHeight = normalizeLineHeight
- self.normalizeTextIndent = normalizeTextIndent
- self.clampImagesToViewport = clampImagesToViewport
- self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes
- }
-}
-
-public struct RDEPUBFontFallbackChain: Codable, Equatable {
-
- public var requestedFamily: String?
-
- public var embeddedFamily: String?
-
- public var systemFallbacks: [String]
-
- public var finalFallback: String
-
- public init(
- requestedFamily: String? = nil,
- embeddedFamily: String? = nil,
- systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"],
- finalFallback: String = ".AppleSystemUIFont"
- ) {
- self.requestedFamily = requestedFamily
- self.embeddedFamily = embeddedFamily
- self.systemFallbacks = systemFallbacks
- self.finalFallback = finalFallback
- }
-}
-
-public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable {
-
- public var family: String?
-
- public var href: String
-
- public var format: String?
-
- public var weight: Int?
-
- public var style: String?
-}
-
-public struct RDEPUBFontRegistrationResult: Codable, Equatable {
-
- public var descriptor: RDEPUBEmbeddedFontDescriptor
-
- public var fileURL: URL?
-
- public var didRegister: Bool
-
- public var errorDescription: String?
-}
-
-public struct RDEPUBCSSCompatibilityReport: Codable, Equatable {
-
- public var unsupportedRules: [String]
-
- public var normalizedRules: [String]
-
- public var fontFailures: [String]
-
- public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) {
- self.unsupportedRules = unsupportedRules
- self.normalizedRules = normalizedRules
- self.fontFailures = fontFailures
- }
-}
-
-struct RDEPUBCSSCompatibilityResult {
-
- var css: String
-
- var report: RDEPUBCSSCompatibilityReport
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift
deleted file mode 100644
index ffae4bf..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift
+++ /dev/null
@@ -1,257 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBAttachmentNormalizer {
-
- private static var didLogFootnoteAttachment = false
-
- private static var didLogCoverAttachment = false
-
-#if canImport(DTCoreText)
-
- func normalize(
- _ attachment: DTTextAttachment,
- fontPointSize: CGFloat,
- maxImageSize: CGSize
- ) {
- Self.normalizeAttachmentLayoutForWXRead(
- attachment,
- fontPointSize: fontPointSize,
- maxImageSize: maxImageSize
- )
- }
-#endif
-
-#if canImport(DTCoreText)
-
- static func normalizeAttachmentLayoutForWXRead(
- _ attachment: DTTextAttachment,
- fontPointSize: CGFloat,
- maxImageSize: CGSize? = nil
- ) {
-
- let pointSize = max(fontPointSize, 1)
-
- let originalSize = attachment.originalSize
-
- if isFootnoteAttachment(attachment) {
-
- let targetWidth = max(round(pointSize), 1)
-
- let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
-
- let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
-
- attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
-
- attachment.verticalAlignment = .baseline
-
- if !didLogFootnoteAttachment {
- didLogFootnoteAttachment = true
- }
- return
- }
-
- if isCoverAttachment(attachment) {
-
- let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
- if originalSize.width > 0, originalSize.height > 0 {
-
- let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
- attachment.displaySize = CGSize(
- width: round(originalSize.width * scale),
- height: round(originalSize.height * scale)
- )
- } else {
-
- attachment.displaySize = maxSize
- }
-
- attachment.verticalAlignment = .baseline
-
- if !didLogCoverAttachment {
- didLogCoverAttachment = true
- }
- return
- }
-
- var resolvedSize = attachment.displaySize
-
- if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
- resolvedSize = originalSize
- }
-
- if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
- resolvedSize = CGSize(width: pointSize, height: pointSize)
- }
-
- if let maxImageSize,
- resolvedSize.width > 0,
- resolvedSize.height > 0,
- (resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
-
- let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
- resolvedSize = CGSize(
- width: round(resolvedSize.width * scale),
- height: round(resolvedSize.height * scale)
- )
- }
-
- attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
-
- attachment.verticalAlignment = .center
- }
-
- static func prepareHTMLElementForReaderRendering(
- _ element: DTHTMLElement,
- style: RDEPUBTextRenderStyle,
- maxImageSize: CGSize? = nil
- ) {
-
- guard let attachment = element.textAttachment else { return }
-
- let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
-
- let fallbackSize = CGSize(
- width: defaultMaxImageSize(fontPointSize: pointSize).width,
- height: defaultMaxImageSize(fontPointSize: pointSize).height
- )
-
- normalizeAttachmentLayoutForWXRead(
- attachment,
- fontPointSize: pointSize,
- maxImageSize: maxImageSize ?? fallbackSize
- )
-
- if isFootnoteAttachment(attachment) {
- element.displayStyle = .inline
- } else if isCoverAttachment(attachment) {
- element.displayStyle = .block
- }
- }
-
- private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
-
- let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
- if lowercasedClasses.contains("qqreader-footnote") {
- return true
- }
- let altText = attachment.attributes["alt"] as? String
- return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize)
- }
-
- private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
-
- let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
-
- let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
- ?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
- return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
- }
-
- private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
-
- let referenceViewport = CGSize(width: 375, height: 667)
-
- let horizontalInset = max(round(fontPointSize), 16)
-
- let verticalInset = max(round(fontPointSize * 1.5), 28)
-
- return CGSize(
- width: max(round(referenceViewport.width - horizontalInset * 2), 1),
- height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
- )
- }
-#endif
-
- static func normalizeAttachmentDisplayIfNeeded(
- in attributes: inout [NSAttributedString.Key: Any],
- font: UIFont
- ) {
-
- guard let attachment = attributes[.attachment] else { return }
-
-#if canImport(DTCoreText)
-
- if let textAttachment = attachment as? DTTextAttachment {
- normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
- attributes[.attachment] = textAttachment
- return
- }
-#endif
-
- if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
-
- let targetHeight = max(round(font.pointSize * 0.86), 1)
- textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
- attributes[.attachment] = textAttachment
- }
- }
-
- static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
- // Check for footnote attachments first — they should show tooltip, not image viewer
- if isFootnoteAttachmentInAttributes(attributes) {
- return .footnote
- }
-
- if let attachment = attributes[.attachment] as? NSTextAttachment {
-
- if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
- return .image
- }
- return .generic
- }
-
- for value in attributes.values {
- let typeName = String(describing: type(of: value)).lowercased()
-
- if typeName.contains("attachment") {
- return typeName.contains("image") ? .image : .generic
- }
- }
-
- return nil
- }
-
- private static func isFootnoteAttachmentInAttributes(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
-#if canImport(DTCoreText)
- if let textAttachment = attributes[.attachment] as? DTTextAttachment {
- return isFootnoteAttachment(textAttachment)
- }
-#endif
- guard let fileAttachment = attributes[.attachment] as? NSTextAttachment else {
- return false
- }
- let label = fileAttachment.accessibilityLabel
- let lowercasedLabel = (label ?? "").lowercased()
- if lowercasedLabel.contains("qqreader-footnote") {
- return true
- }
- let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size
- return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize)
- }
-
- // Footnote images without the qqreader-footnote class are recognized by their
- // alt text carrying the note body. Short alts ("logo", "图1") are ordinary
- // accessibility descriptions, and note markers are small inline icons, so both
- // conditions must hold before an image is shrunk to footnote size.
- private static let minimumFootnoteAltTextLength = 8
-
- private static let maximumFootnoteImageDimension: CGFloat = 50
-
- private static func hasFootnoteAltText(_ text: String?) -> Bool {
- guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else {
- return false
- }
- return trimmed.count >= minimumFootnoteAltTextLength
- }
-
- private static func isFootnoteSizedImage(_ size: CGSize) -> Bool {
- guard size.width > 0, size.height > 0 else { return false }
- return size.width <= maximumFootnoteImageDimension
- && size.height <= maximumFootnoteImageDimension
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift
deleted file mode 100644
index cb75f0a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift
+++ /dev/null
@@ -1,59 +0,0 @@
-import Foundation
-
-struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage {
-
- func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
-
- guard let regex = try? NSRegularExpression(
- pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#,
- options: [.caseInsensitive]
- ) else {
- return html
- }
-
- let nsHTML = html as NSString
-
- let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
- guard !matches.isEmpty else { return html }
-
- var rewritten = html
- for match in matches.reversed() {
-
- guard match.numberOfRanges > 3,
- let fullRange = Range(match.range(at: 0), in: rewritten),
- let tagNameRange = Range(match.range(at: 1), in: html),
- let attributesRange = Range(match.range(at: 2), in: html),
- let fragmentRange = Range(match.range(at: 3), in: html) else {
- continue
- }
-
- let tagName = String(html[tagNameRange])
- let attributes = String(html[attributesRange])
- let fragmentID = String(html[fragmentRange])
-
- guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else {
- continue
- }
-
- let marker = RDEPUBCFIGenerator.makeOffsetCFI(
- href: context.href,
- fileIndex: context.spineIndex ?? 0,
- chapterOffset: 0,
- fragmentID: fragmentID
- ).rawValue
-
- let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">"
- rewritten.replaceSubrange(fullRange, with: replacement)
- }
-
- return rewritten
- }
-
- private static func escapeAttribute(_ value: String) -> String {
- value
- .replacingOccurrences(of: "&", with: "&")
- .replacingOccurrences(of: "\"", with: """)
- .replacingOccurrences(of: "<", with: "<")
- .replacingOccurrences(of: ">", with: ">")
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift
deleted file mode 100644
index 6869ca2..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-
-/// 支持加密图片的 DTCoreText 图片附件。
-///
-/// DTCoreText 解析 `
` 时会直接读磁盘文件来取图并推算尺寸;
-/// 对加密图片该读取会解码失败,表现为 `image == nil` 且 `contentURL` 被保留。
-/// 本子类在 super 初始化完成后接管这种失败场景:
-/// 经 `RDEPUBResourceAccessRegistry` 反查 provider 解密数据并调用 `setImage`
-/// (setter 会自动补齐 originalSize / displaySize,后续 willFlush 阶段的
-/// 附件规范化与绘制均按明文图片的既有路径工作)。
-///
-/// 通过 `DTTextAttachment.registerClass(_:forTagName:)` 全局注册,
-/// 未启用 provider 的书行为与父类完全一致。
-final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment {
-
- private static var didRegisterTagClass = false
-
- private static let registerLock = NSLock()
-
- /// 将本类注册为 `
` 标签的附件类(进程内一次性,线程安全)
- static func registerTagClassIfNeeded() {
- registerLock.lock()
- defer { registerLock.unlock() }
- guard !didRegisterTagClass else { return }
- DTTextAttachment.registerClass(RDEPUBDecryptingImageAttachment.self, forTagName: "img")
- didRegisterTagClass = true
- }
-
- override init!(element: DTHTMLElement!, options: [AnyHashable: Any]! = [:]) {
- super.init(element: element, options: options)
- decryptImageIfNeeded()
- }
-
- /// DTCoreText / UIKit 在部分 HTML 图片路径会改用 NSTextAttachment 的
- /// `init(data:ofType:)` 创建附件。若子类未实现,Foundation 会直接触发
- /// “Use of unimplemented initializer” 致命错误。
- override init(data contentData: Data?, ofType uti: String?) {
- super.init(data: contentData, ofType: uti)
- if image == nil, let contentData {
- image = UIImage(data: contentData)
- }
- }
-
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- }
-
- private func decryptImageIfNeeded() {
- // super 已成功取到图(明文图片 / data: 内联图)时不干预
- guard image == nil,
- let contentURL,
- contentURL.isFileURL,
- let data = RDEPUBResourceAccessRegistry.providedResourceData(at: contentURL.standardizedFileURL),
- let decrypted = UIImage(data: data) else {
- return
- }
- image = decrypted
- // 对齐 DTCoreText 本地图片加载成功的行为:置空 contentURL,
- // 避免下游误当作待加载的远程 / 磁盘图片再次直读密文
- self.contentURL = nil
- }
-}
-#endif
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift
deleted file mode 100644
index be5f5c3..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift
+++ /dev/null
@@ -1,160 +0,0 @@
-import UIKit
-
-import CoreText
-
-struct RDEPUBFontNormalizer {
-
- private static var registeredFontPaths = Set()
-
- @discardableResult
- func registerEmbeddedFonts(
- html: String,
- inlinedCSS: String,
- input: RDEPUBTypesettingInput
- ) -> [RDEPUBFontRegistrationResult] {
-
- Self.registerEmbeddedFonts(
- in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
- chapterHref: input.href,
- resourceResolver: input.resourceResolver
- )
- }
-
- @discardableResult
- static func registerEmbeddedFonts(
- in css: String,
- chapterHref: String,
- resourceResolver: RDEPUBResourceResolver?
- ) -> [RDEPUBFontRegistrationResult] {
-
- guard let resourceResolver,
-
- let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
-
- let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
- return []
- }
-
- var results: [RDEPUBFontRegistrationResult] = []
- let nsCSS = css as NSString
-
- for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
- guard faceMatch.numberOfRanges > 1 else { continue }
-
- let block = nsCSS.substring(with: faceMatch.range(at: 1))
- let nsBlock = block as NSString
-
- let family = declarationValue(named: "font-family", in: block)?
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
-
- let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init)
-
- let style = declarationValue(named: "font-style", in: block)
-
- for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
- guard urlMatch.numberOfRanges > 1 else { continue }
-
- let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
-
- let descriptor = RDEPUBEmbeddedFontDescriptor(
- family: family,
- href: rawReference,
- format: nil,
- weight: weight,
- style: style
- )
-
- guard !rawReference.isEmpty,
- !rawReference.hasPrefix("data:"),
- !rawReference.hasPrefix("http:"),
- !rawReference.hasPrefix("https:"),
-
- let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
-
- results.append(
- RDEPUBFontRegistrationResult(
- descriptor: descriptor,
- fileURL: nil,
- didRegister: false,
- errorDescription: "Font URL could not be resolved"
- )
- )
- continue
- }
-
- let didRegister = registerFontIfNeeded(at: fileURL)
- results.append(
- RDEPUBFontRegistrationResult(
- descriptor: descriptor,
- fileURL: fileURL,
- didRegister: didRegister,
- errorDescription: didRegister ? nil : "Font was already registered or registration failed"
- )
- )
- }
- }
- return results
- }
-
- @discardableResult
- static func registerFontIfNeeded(at fileURL: URL) -> Bool {
-
- let standardizedPath = fileURL.standardizedFileURL.path
-
- guard !registeredFontPaths.contains(standardizedPath) else { return true }
-
- let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
- if registered {
-
- registeredFontPaths.insert(standardizedPath)
- }
- return registered
- }
-
- static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
- guard let sourceFont else {
- return baseFont
- }
-
- let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
-
- if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
- return UIFont(descriptor: descriptor, size: baseFont.pointSize)
- }
-
- return baseFont
- }
-
- static func inlineStyleCSS(in html: String) -> String {
-
- guard let regex = try? NSRegularExpression(pattern: #""#, options: [.caseInsensitive]) else {
- return ""
- }
- let nsHTML = html as NSString
-
- return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
- .compactMap { match in
- guard match.numberOfRanges > 1 else { return nil }
- return nsHTML.substring(with: match.range(at: 1))
- }
- .joined(separator: "\n")
- }
-
- private static func declarationValue(named name: String, in block: String) -> String? {
-
- guard let regex = try? NSRegularExpression(
- pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"#
- ) else {
- return nil
- }
- let nsBlock = block as NSString
-
- guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)),
- match.numberOfRanges > 1 else {
- return nil
- }
-
- return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift
deleted file mode 100644
index 45272af..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-
-import Foundation
-
-struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
-
- func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
- Self.injectFragmentMarkers(into: html)
- }
-
- func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
- Self.extractFragmentOffsets(from: attributedString)
- }
-
- static func injectFragmentMarkers(into html: String) -> String {
- guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
- return html
- }
- return regex.stringByReplacingMatches(
- in: html,
- options: [],
- range: NSRange(location: 0, length: html.utf16.count),
- withTemplate: "${id=$2}$1"
- )
- }
-
-
-
-
-
- static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
-
- let markerPattern = #"\$\{id=([^}]+)\}"#
- guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
- return [:]
- }
-
-
- let mutableString = NSMutableString(string: attributedString.string)
-
- var fragmentOffsets: [String: Int] = [:]
-
- var searchRange = NSRange(location: 0, length: mutableString.length)
-
- var offsetAdjustment = 0
-
-
- while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
-
- let fullMatch = mutableString.substring(with: match.range) as NSString
- let fragmentID = fullMatch
- .replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
- .replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
-
- let adjustedLocation = max(0, match.range.location + offsetAdjustment)
-
- fragmentOffsets[fragmentID] = adjustedLocation
-
- attributedString.deleteCharacters(in: match.range)
- mutableString.deleteCharacters(in: match.range)
-
- offsetAdjustment -= match.range.length
-
- searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
- }
-
- return fragmentOffsets
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift
deleted file mode 100644
index 10aaa02..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift
+++ /dev/null
@@ -1,270 +0,0 @@
-import Foundation
-
-struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
-
- func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
- Self.normalizeHTML(html)
- }
-
- static func normalizeHTML(_ html: String) -> String {
- var cleanedHTML = html
-
- let replacements: [(pattern: String, template: String)] = [
- (#"
分页符"#, ""),
- (#"\r"#, "\n"),
- (#"\n+"#, "\n")
- ]
-
- for replacement in replacements {
- if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
- cleanedHTML = regex.stringByReplacingMatches(
- in: cleanedHTML,
- options: [],
- range: NSRange(location: 0, length: cleanedHTML.utf16.count),
- withTemplate: replacement.template
- )
- }
- }
-
- cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
- cleanedHTML = normalizeBodyLeadingSpacing(in: cleanedHTML)
- return cleanedHTML
- }
-
- private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
- var normalized = html
-
- if let bodyPicContainerRegex = try? NSRegularExpression(
- pattern: #"]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)"#,
- options: [.caseInsensitive]
- ) {
- normalized = replaceMatches(
- using: bodyPicContainerRegex,
- in: normalized
- ) { tag in
-
- guard let imageTagRegex = try? NSRegularExpression(pattern: #"
]*>"#, options: [.caseInsensitive]) else {
- return tag
- }
-
-
- return replaceMatches(
- using: imageTagRegex,
- in: tag
- ) { imageTag in
- mergeHTMLAttributes(
- into: imageTag,
- requiredClass: "bodyPic",
- styleFragments: [
- "wr-vertical-center-style:2",
- "max-width:100%",
- "height:auto",
- "display:block",
- "margin-left:auto",
- "margin-right:auto"
- ]
- )
- }
- }
- }
-
-
- if let footnoteRegex = try? NSRegularExpression(
- pattern: #"
]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
- options: [.caseInsensitive]
- ) {
- normalized = replaceMatches(
- using: footnoteRegex,
- in: normalized
- ) { tag in
-
- mergeHTMLAttributes(
- into: tag,
- requiredClass: nil,
- styleFragments: [
- "width:1em",
- "height:1em",
- "vertical-align:middle",
- "display:inline-block"
- ]
- )
- }
- }
-
-
- if let coverRegex = try? NSRegularExpression(
- pattern: #"]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(
]*>)\s*
"#,
- options: [.caseInsensitive]
- ) {
- normalized = replaceMatches(
- using: coverRegex,
- in: normalized
- ) { tag in
-
- guard let imageTagRegex = try? NSRegularExpression(pattern: #"
]*>"#, options: [.caseInsensitive]),
- let imageMatch = imageTagRegex.firstMatch(
- in: tag,
- options: [],
- range: NSRange(location: 0, length: (tag as NSString).length)
- ),
- let imageRange = Range(imageMatch.range, in: tag) else {
- return tag
- }
-
- let imageTag = String(tag[imageRange])
- let normalizedImageTag = mergeHTMLAttributes(
- into: imageTag,
- requiredClass: "rd-front-cover-image",
- styleFragments: [
- "display:block",
- "width:100%",
- "height:auto",
- "margin-left:auto",
- "margin-right:auto"
- ]
- )
- return tag.replacingCharacters(in: imageRange, with: normalizedImageTag)
- }
- }
-
- return normalized
- }
-
- private static func normalizeBodyLeadingSpacing(in html: String) -> String {
- var normalized = html
-
- if let bodyStartRegex = try? NSRegularExpression(
- pattern: #"(]*>)\s+"#,
- options: [.caseInsensitive]
- ) {
- normalized = bodyStartRegex.stringByReplacingMatches(
- in: normalized,
- options: [],
- range: NSRange(location: 0, length: (normalized as NSString).length),
- withTemplate: "$1"
- )
- }
-
- if let bodyEndRegex = try? NSRegularExpression(
- pattern: #"\s+()"#,
- options: [.caseInsensitive]
- ) {
- normalized = bodyEndRegex.stringByReplacingMatches(
- in: normalized,
- options: [],
- range: NSRange(location: 0, length: (normalized as NSString).length),
- withTemplate: "$1"
- )
- }
-
- guard let firstBlockRegex = try? NSRegularExpression(
- pattern: #"(]*>)(\s*)(<(?h[1-6]|p|div|blockquote|section|article|ul|ol)\b[^>]*>)"#,
- options: [.caseInsensitive]
- ) else {
- return normalized
- }
-
- let nsNormalized = normalized as NSString
- let fullRange = NSRange(location: 0, length: nsNormalized.length)
- guard let match = firstBlockRegex.firstMatch(in: normalized, options: [], range: fullRange),
- match.numberOfRanges >= 4,
- let bodyRange = Range(match.range(at: 1), in: normalized),
- let blockRange = Range(match.range(at: 3), in: normalized) else {
- return normalized
- }
-
- let prefix = String(normalized[.. String
- ) -> String {
- let nsSource = source as NSString
-
- let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
- guard !matches.isEmpty else { return source }
-
- var rewritten = source
- for match in matches.reversed() {
- guard let range = Range(match.range, in: rewritten) else { continue }
- let original = String(rewritten[range])
- rewritten.replaceSubrange(range, with: transform(original))
- }
- return rewritten
- }
-
- static func mergeHTMLAttributes(
- into tag: String,
- requiredClass: String?,
- styleFragments: [String]
- ) -> String {
- var rewritten = tag
- if let requiredClass {
- if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
- let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
- match.numberOfRanges > 1 {
- let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1))
- if !existingClasses.localizedCaseInsensitiveContains(requiredClass) {
- let replacement = #"class="\#(existingClasses) \#(requiredClass)""#
- if let range = Range(match.range, in: rewritten) {
- rewritten.replaceSubrange(range, with: replacement)
- }
- }
- } else if let closing = rewritten.lastIndex(of: ">") {
- rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing)
- }
- }
-
- let styleValue = styleFragments.joined(separator: ";") + ";"
- if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
- let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
- match.numberOfRanges > 1 {
- let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
- let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue
- let replacement = #"style="\#(merged)""#
- if let range = Range(match.range, in: rewritten) {
- rewritten.replaceSubrange(range, with: replacement)
- }
- } else if let closing = rewritten.lastIndex(of: ">") {
- rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing)
- }
-
- return rewritten
- }
-
- static func injectBaseHref(into html: String, baseURL: URL?) -> String {
- guard let baseURL else {
- return html
- }
- let baseTag = " "
- if html.range(of: " ", options: [.caseInsensitive]) != nil {
- return html.replacingOccurrences(of: "", with: "\n\(baseTag)", options: [.caseInsensitive])
- }
- if let htmlTagRange = html.range(of: "", range: htmlTagRange.lowerBound..\n\(baseTag)\n")
- }
- return "\n\(baseTag)\n\n" + html
- }
-
- static func string(from size: CGSize) -> String {
- "{\(Int(round(size.width))), \(Int(round(size.height)))}"
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift
deleted file mode 100644
index 8bc00b1..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift
+++ /dev/null
@@ -1,191 +0,0 @@
-import Foundation
-
-struct RDEPUBRenderDiagnosticsCollector {
-
- private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
-
- private static let imageSourcePattern = #"
]*src\s*=\s*["']([^"']+)["'][^>]*>"#
-
-
-
-
-
-
- func collect(
- in html: String,
- input: RDEPUBTypesettingInput
- ) -> [RDEPUBTextResourceReferenceDiagnostic] {
- Self.collectImageDiagnostics(
- in: html,
- chapterHref: input.href,
- baseURL: input.baseURL,
- resourceResolver: input.resourceResolver
- )
- }
-
-
-
-
- static func collectImageDiagnostics(
- in html: String,
- chapterHref: String,
- baseURL: URL?,
- resourceResolver: RDEPUBResourceResolver?
- ) -> [RDEPUBTextResourceReferenceDiagnostic] {
- guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else {
- return []
- }
- let nsHTML = html as NSString
- return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
- guard match.numberOfRanges > 1 else { return nil }
- let href = nsHTML.substring(with: match.range(at: 1))
- return resolveReference(
- href,
- kind: .image,
- chapterHref: chapterHref,
- baseURL: baseURL,
- resourceResolver: resourceResolver
- ).diagnostic
- }
- }
-
-
-
-
- static func inlineLinkedStyleSheets(
- in html: String,
- chapterHref: String,
- baseURL: URL?,
- resourceResolver: RDEPUBResourceResolver?
- ) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) {
- guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else {
- return (html, "", [])
- }
-
- let nsHTML = html as NSString
- let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
- guard !matches.isEmpty else {
- return (html, "", [])
- }
-
-
- var rewrittenHTML = html
-
- var inlinedCSSBlocks: [String] = []
-
- var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
-
-
- for match in matches.reversed() {
- guard match.numberOfRanges > 1 else { continue }
- let href = nsHTML.substring(with: match.range(at: 1))
- let resolution = resolveReference(
- href,
- kind: .stylesheet,
- chapterHref: chapterHref,
- baseURL: baseURL,
- resourceResolver: resourceResolver
- )
- diagnostics.append(resolution.diagnostic)
-
- if let fileURL = resolution.resolvedFileURL,
- let css = styleSheetContent(at: fileURL, resourceResolver: resourceResolver),
- resolution.diagnostic.existsOnDisk {
- let cssWithResolvedURLs = rewriteCSSResourceURLs(
- in: css,
- styleSheetFileURL: fileURL
- )
- inlinedCSSBlocks.append(cssWithResolvedURLs)
- }
-
- if let range = Range(match.range, in: rewrittenHTML) {
- rewrittenHTML.replaceSubrange(range, with: "")
- }
- }
-
- return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
- }
-
- /// 读取样式表内容:优先经加密资源 provider 解密,明文文件保持原直读路径
- private static func styleSheetContent(
- at fileURL: URL,
- resourceResolver: RDEPUBResourceResolver?
- ) -> String? {
- if let provided = resourceResolver?.providedResourceData(at: fileURL) {
- return String(data: provided, encoding: .utf8)
- ?? String(data: provided, encoding: .utf16)
- }
- return try? String(contentsOf: fileURL)
- }
-
-
- static func rewriteCSSResourceURLs(
- in css: String,
- styleSheetFileURL: URL
- ) -> String {
- guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
- return css
- }
-
- let nsCSS = css as NSString
- let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length))
- guard !matches.isEmpty else {
- return css
- }
-
- var rewrittenCSS = css
-
- for match in matches.reversed() {
- guard match.numberOfRanges > 1 else { continue }
-
- let rawValue = nsCSS.substring(with: match.range(at: 1))
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
- guard !rawValue.isEmpty else { continue }
-
- if rawValue.hasPrefix("data:")
- || rawValue.hasPrefix("http:")
- || rawValue.hasPrefix("https:") {
- continue
- }
-
- guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
- continue
- }
-
- let replacement = "url(\"\(resolvedURL.absoluteString)\")"
- if let range = Range(match.range, in: rewrittenCSS) {
- rewrittenCSS.replaceSubrange(range, with: replacement)
- }
- }
- return rewrittenCSS
- }
-
- static func resolveReference(
- _ reference: String,
- kind: RDEPUBTextResourceReferenceKind,
- chapterHref: String,
- baseURL: URL?,
- resourceResolver: RDEPUBResourceResolver?
- ) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
-
- let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
-
- let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
-
- let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
- ?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
-
- let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
-
- let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
- kind: kind,
- chapterHref: chapterHref,
- originalReference: trimmedReference,
- normalizedHref: normalizedHref,
- resolvedFileURL: resolvedFileURL,
- existsOnDisk: existsOnDisk
- )
- return (normalizedHref, resolvedFileURL, diagnostic)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift
deleted file mode 100644
index cc941ac..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift
+++ /dev/null
@@ -1,336 +0,0 @@
-
-import Foundation
-import UIKit
-
-struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
-
- private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
-
- func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
- Self.injectPaginationSemanticMarkers(into: html)
- }
-
- func apply(to attributedString: NSMutableAttributedString) {
- Self.applyPaginationSemantics(in: attributedString)
- }
-
- static func injectPaginationSemanticMarkers(into html: String) -> String {
- guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
- return html
- }
-
- let nsHTML = html as NSString
- let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
- guard !matches.isEmpty else {
- return html
- }
-
- var output = ""
-
- var cursor = 0
-
- var openTagStack: [(name: String, id: String)] = []
-
- var nextMarkerID = 0
-
- for match in matches {
- let tagRange = match.range
- guard tagRange.location >= cursor else { continue }
- output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
-
- let tag = nsHTML.substring(with: tagRange)
- let loweredTag = tag.lowercased()
-
- let tagName = htmlTagName(from: loweredTag)
-
- if loweredTag.hasPrefix(""), let tagName {
-
- if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
- let markerID = openTagStack.remove(at: index).id
- output += semanticEndMarker(id: markerID)
- }
- output += tag
- } else if let tagName,
- let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
-
- nextMarkerID += 1
- let markerID = String(nextMarkerID)
- let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
- if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
-
- output += startMarker + tag + semanticEndMarker(id: markerID)
- } else {
-
- openTagStack.append((name: tagName, id: markerID))
- output += tag + startMarker
- }
- } else {
-
- output += tag
- }
-
- cursor = tagRange.location + tagRange.length
- }
-
- output += nsHTML.substring(from: cursor)
- return output
- }
-
- static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
- guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
- return
- }
-
- let mutableString = NSMutableString(string: attributedString.string)
-
- var searchRange = NSRange(location: 0, length: mutableString.length)
-
- var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
-
- while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
-
- let kind = mutableString.substring(with: match.range(at: 1))
-
- let payload = mutableString.substring(with: match.range(at: 2))
-
- let markerLocation = match.range.location
-
- attributedString.deleteCharacters(in: match.range)
- mutableString.deleteCharacters(in: match.range)
-
- if kind == "start" {
-
- let semantics = parseSemanticMarkerPayload(payload)
- openRanges[semantics.id] = (markerLocation, semantics)
- } else {
-
- let markerID = parseSemanticEndID(payload)
- if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
- let length = max(markerLocation - opened.location, 0)
- if length > 0 {
- apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
- }
- }
- }
-
- searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
- }
- }
-
- static func htmlTagName(from loweredTag: String) -> String? {
- let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.hasPrefix("<") else { return nil }
- let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
- let name = body.prefix { $0.isLetter || $0.isNumber }
- return name.isEmpty ? nil : String(name)
- }
-
- private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
- let loweredTag = rawTag.lowercased()
- let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
- let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
- let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
-
- guard blockKind != nil || !hints.isEmpty || placement != nil else {
- return nil
- }
-
- return RDPaginationSemantics(
- id: "",
- blockKind: blockKind,
- hints: hints,
- attachmentPlacement: placement
- )
- }
-
- private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
- if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
- return .attachment
- }
- switch tagName {
- case "h1", "h2", "h3", "h4", "h5", "h6":
- return .generic
- case "blockquote":
- return .blockquote
- case "ul", "ol", "li":
- return .list
- case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
- return .table
- case "pre", "code":
- return .code
- case "p":
- return .paragraph
- case "div":
- if rawTag.contains("code") || rawTag.contains("highlight") {
- return .code
- }
- if rawTag.contains("quote") || rawTag.contains("blockquote") {
- return .blockquote
- }
- if rawTag.contains("table") {
- return .table
- }
- if rawTag.contains("list") {
- return .list
- }
- return .generic
- default:
- return nil
- }
- }
-
- private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
- var hints: [RDEPUBTextSemanticHint] = []
- if rawTag.contains("avoidpagebreakinside") ||
- rawTag.contains("break-inside: avoid") ||
- rawTag.contains("page-break-inside: avoid") ||
- ["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
- hints.append(.avoidPageBreakInside)
- }
- if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) ||
- rawTag.contains("subhead") ||
- rawTag.contains("firsttitle") ||
- rawTag.contains("secondtitle") ||
- rawTag.contains("thirdtitle") ||
- rawTag.contains("fourthtitle") ||
- rawTag.contains("fifthtitle") ||
- rawTag.contains("sixthtitle") {
- hints.append(.keepWithNext)
- }
- if rawTag.contains("pagebreakbefore") ||
- rawTag.contains("page-break-before: always") ||
- rawTag.contains("break-before: page") {
- hints.append(.pageBreakBefore)
- }
- if rawTag.contains("pagebreakafter") ||
- rawTag.contains("page-break-after: always") ||
- rawTag.contains("break-after: page") {
- hints.append(.pageBreakAfter)
- }
- if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
- hints.append(.pageRelate)
- }
- return hints
- .reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
- if !result.contains(hint) {
- result.append(hint)
- }
- }
- .sorted { $0.rawValue < $1.rawValue }
- }
-
- private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
- guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
- return nil
- }
- if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
- return .centered
- }
- if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
- return .baseline
- }
- return .inline
- }
-
- private static func isVoidHTMLTag(_ tagName: String) -> Bool {
- ["img", "br", "hr", "input", "meta", "link"].contains(tagName)
- }
-
- private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
- var segments = ["id=\(id)"]
- if let blockKind = semantics.blockKind {
- segments.append("block=\(blockKind.rawValue)")
- }
- if !semantics.hints.isEmpty {
- segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
- }
- if let placement = semantics.attachmentPlacement {
- segments.append("placement=\(placement.rawValue)")
- }
- return "${rd-sem-start:\(segments.joined(separator: ";"))}"
- }
-
- private static func semanticEndMarker(id: String) -> String {
- "${rd-sem-end:id=\(id)}"
- }
-
- private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
- var values: [String: String] = [:]
- payload.split(separator: ";").forEach { entry in
- let parts = entry.split(separator: "=", maxSplits: 1)
- guard parts.count == 2 else { return }
- values[String(parts[0])] = String(parts[1])
- }
- let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
- let hints = values["hints"]?
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
- let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
- return RDPaginationSemantics(
- id: values["id"] ?? UUID().uuidString,
- blockKind: blockKind,
- hints: hints,
- attachmentPlacement: placement
- )
- }
-
- private static func parseSemanticEndID(_ payload: String) -> String? {
- payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
- }
-
- private static func apply(
- semantics: RDPaginationSemantics,
- to range: NSRange,
- in attributedString: NSMutableAttributedString
- ) {
- var attributes: [NSAttributedString.Key: Any] = [:]
- attributes[.rdPageBlockRange] = NSStringFromRange(range)
- if let blockKind = semantics.blockKind {
- attributes[.rdPageBlockKind] = blockKind.rawValue
- }
- if !semantics.hints.isEmpty {
- attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
- }
- if let placement = semantics.attachmentPlacement {
- attributes[.rdPageAttachmentPlacement] = placement.rawValue
- }
- guard !attributes.isEmpty else { return }
- attributedString.addAttributes(attributes, range: range)
- }
-
- static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
- if let rawValue = attributes[.rdPageBlockKind] as? String,
- let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
- return blockKind
- }
- return nil
- }
-
- static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
- if let rawValue = attributes[.rdPageSemanticHints] as? String {
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- return hints.isEmpty ? nil : hints
- }
- return nil
- }
-
- static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
- if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
- let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
- return placement
- }
- if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes), attachmentKind == .image {
- return .inline
- }
- return nil
- }
-
- struct RDPaginationSemantics {
- var id: String
- var blockKind: RDEPUBTextBlockKind?
- var hints: [RDEPUBTextSemanticHint]
- var attachmentPlacement: RDEPUBTextAttachmentPlacement?
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift
deleted file mode 100644
index 45015ac..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift
+++ /dev/null
@@ -1,289 +0,0 @@
-import UIKit
-
-struct RDEPUBStyleSheetComposition {
-
- var html: String
-
- var layers: [RDEPUBTextStyleSheetLayer]
-
- var inlinedCSS: String
-
- var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
-
- var compatibilityReport: RDEPUBCSSCompatibilityReport
-}
-
-struct RDEPUBStyleSheetComposer {
-
- func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
- let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
- in: html,
- chapterHref: input.href,
- baseURL: input.baseURL,
- resourceResolver: input.resourceResolver
- )
- let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
- let layers = Self.makeStyleSheetLayers(
- style: input.style,
- epubCSS: compatibility.css,
- contentLanguageCode: input.contentLanguageCode,
- sourceHTML: input.rawHTML
- )
- let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(
- into: stylesheetHrefReplacements.html,
- baseURL: input.baseURL
- )
- let htmlWithDefaultLayers = Self.injectStyleTag(
- into: htmlWithBase,
- styleID: "rd-native-default-replace-dark",
- css: layers
- .filter { $0.kind != .user && $0.kind != .epub }
- .map(\.css)
- .joined(separator: "\n\n"),
- position: .headStart
- )
- let htmlWithEPUBLayer = Self.injectStyleTag(
- into: htmlWithDefaultLayers,
- styleID: "rd-native-epub",
- css: layers.first(where: { $0.kind == .epub })?.css ?? "",
- position: .headEnd
- )
- let composedHTML = Self.injectStyleTag(
- into: htmlWithEPUBLayer,
- styleID: "rd-native-user",
- css: layers.first(where: { $0.kind == .user })?.css ?? "",
- position: .headEnd
- )
-
- return RDEPUBStyleSheetComposition(
- html: composedHTML,
- layers: layers,
- inlinedCSS: compatibility.css,
- diagnostics: stylesheetHrefReplacements.diagnostics,
- compatibilityReport: compatibility.report
- )
- }
-
- static func makeStyleSheetLayers(
- style: RDEPUBTextRenderStyle,
- epubCSS: String,
- contentLanguageCode: String?,
- sourceHTML: String
- ) -> [RDEPUBTextStyleSheetLayer] {
- let useLatinReplace = prefersLatinLanguageCSS(
- languageCode: contentLanguageCode,
- sourceHTML: sourceHTML
- )
- var layers: [RDEPUBTextStyleSheetLayer] = [
- .init(kind: .default, css: defaultCSS()),
- .init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
- ]
- if isDarkTheme(style: style) {
- layers.append(.init(kind: .dark, css: darkCSS(style: style)))
- }
- if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- layers.append(.init(kind: .epub, css: epubCSS))
- }
- layers.append(.init(kind: .user, css: userCSS(style: style)))
- return layers
- }
-
- enum StyleInjectionPosition {
-
- case headStart
-
- case headEnd
- }
-
- static func injectStyleTag(
- into html: String,
- styleID: String,
- css: String,
- position: StyleInjectionPosition
- ) -> String {
- let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedCSS.isEmpty else {
- return html
- }
-
- let styleTag = ""
- switch position {
- case .headStart:
- if html.range(of: "", options: [.caseInsensitive]) != nil {
- return html.replacingOccurrences(of: "", with: "\n\(styleTag)", options: [.caseInsensitive])
- }
- case .headEnd:
- if html.range(of: "", options: [.caseInsensitive]) != nil {
- return html.replacingOccurrences(of: "", with: "\(styleTag)\n", options: [.caseInsensitive])
- }
- }
-
- if html.range(of: " String {
- RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
- }
-
- private static func replaceCSS(useLatinVariant: Bool) -> String {
- let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
- return RDEPUBAssetRepository.string(for: asset)
- }
-
- private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
- let background = style.backgroundColor?.rd_cssString ?? "rgba(0, 0, 0, 1.000)"
- let text = style.textColor?.rd_cssString ?? "rgba(255, 255, 255, 1.000)"
- return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
- html, body {
- background: \(background) !important;
- color: \(text) !important;
- }
- a {
- color: \(text) !important;
- }
- """
- }
-
- private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
- let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
- let text = style.textColor.map { "color: \($0.rd_cssString) !important;" } ?? ""
- let background = style.backgroundColor.map { "background: \($0.rd_cssString) !important;" } ?? ""
- return """
- html, body {
- font-family: "\(style.font.familyName)" !important;
- font-size: \(String(format: "%.3f", style.font.pointSize))px !important;
- line-height: \(String(format: "%.3f", lineHeight)) !important;
- \(text)
- \(background)
- }
- """
- }
-
- private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool {
- guard let backgroundColor = style.backgroundColor else {
- return false
- }
- var red: CGFloat = 0
- var green: CGFloat = 0
- var blue: CGFloat = 0
- var alpha: CGFloat = 0
- backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
- let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
- return luminance < 0.5
- }
-
- private static func prefersLatinLanguageCSS(
- languageCode: String?,
- sourceHTML: String
- ) -> Bool {
- let candidateCodes = inferredLanguageCodes(
- explicitLanguageCode: languageCode,
- sourceHTML: sourceHTML
- )
-
- if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
- return true
- }
- if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
- return false
- }
-
- let textSample = plainTextSample(from: sourceHTML)
- guard !textSample.isEmpty else { return false }
-
- var alphabeticCount = 0
- var latinCount = 0
- for scalar in textSample.unicodeScalars {
- guard CharacterSet.letters.contains(scalar) else { continue }
- alphabeticCount += 1
- if isLatinScalar(scalar) {
- latinCount += 1
- }
- }
-
- guard alphabeticCount >= 80 else { return false }
- return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
- }
-
- private static func inferredLanguageCodes(
- explicitLanguageCode: String?,
- sourceHTML: String
- ) -> [String] {
- var codes: [String] = []
- if let explicitLanguageCode {
- let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- if !normalized.isEmpty {
- codes.append(normalized)
- }
- }
-
- if let regex = try? NSRegularExpression(
- pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
- options: [.caseInsensitive]
- ) {
- let nsHTML = sourceHTML as NSString
- let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
- for match in regex.matches(in: sourceHTML, options: [], range: range) {
- guard match.numberOfRanges > 1 else { continue }
- let code = nsHTML.substring(with: match.range(at: 1))
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- if !code.isEmpty {
- codes.append(code)
- }
- }
- }
-
- return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
- }
-
- private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
- let normalized = code.lowercased()
- if normalized.contains("latn") {
- return true
- }
-
- let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
- let latinPrefixes: Set = [
- "en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
- "is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
- "vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
- ]
- return latinPrefixes.contains(prefix)
- }
-
- private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
- let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
- return ["zh", "ja", "ko"].contains(prefix)
- }
-
- private static func plainTextSample(from html: String) -> String {
- let maxLength = min(html.count, 20_000)
- let sample = String(html.prefix(maxLength))
- let withoutTags = sample.replacingOccurrences(
- of: #"<[^>]+>"#,
- with: " ",
- options: .regularExpression
- )
- return withoutTags.replacingOccurrences(
- of: #"&[A-Za-z0-9#]+;"#,
- with: " ",
- options: .regularExpression
- )
- }
-
- private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
- switch scalar.value {
- case 0x0041...0x007A,
- 0x00C0...0x00FF,
- 0x0100...0x024F,
- 0x1E00...0x1EFF:
- return true
- default:
- return false
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift
deleted file mode 100644
index 04cd13f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift
+++ /dev/null
@@ -1,185 +0,0 @@
-import UIKit
-import CoreText
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-enum RDEPUBTextRendererSupport {
-
- static func makeChapterRenderRequest(
- href: String,
- title: String,
- rawHTML: String,
- baseURL: URL?,
- style: RDEPUBTextRenderStyle,
- resourceResolver: RDEPUBResourceResolver?,
- contentLanguageCode: String? = nil,
- pageSize: CGSize? = nil,
- layoutConfig: RDEPUBTextLayoutConfig? = nil
- ) -> RDEPUBTextChapterRenderRequest {
-
- let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
- into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
- )
-
- let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
- in: normalizedHTML,
- chapterHref: href,
- baseURL: baseURL,
- resourceResolver: resourceResolver
- )
- let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
-
- let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
- style: style,
- epubCSS: compatibility.css,
- contentLanguageCode: contentLanguageCode,
- sourceHTML: rawHTML
- )
-
- RDEPUBFontNormalizer.registerEmbeddedFonts(
- in: compatibility.css + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
- chapterHref: href,
- resourceResolver: resourceResolver
- )
-
- let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
-
- let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
- into: htmlWithBase,
- styleID: "rd-native-default-replace-dark",
- css: layers
- .filter { $0.kind != .user && $0.kind != .epub }
- .map(\.css)
- .joined(separator: "\n\n"),
- position: .headStart
- )
-
- let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
- into: htmlWithDefaultLayers,
- styleID: "rd-native-epub",
- css: layers.first(where: { $0.kind == .epub })?.css ?? "",
- position: .headEnd
- )
-
- let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
- into: htmlWithEPUBLayer,
- styleID: "rd-native-user",
- css: layers.first(where: { $0.kind == .user })?.css ?? "",
- position: .headEnd
- )
-
- let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
-
- let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
- in: markedHTML,
- chapterHref: href,
- baseURL: baseURL,
- resourceResolver: resourceResolver
- )
-
- let context = RDEPUBTextChapterContext(
- href: href,
- title: title,
- html: markedHTML,
- baseURL: baseURL,
- stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
- resourceDiagnostics: resourceDiagnostics,
- styleCompatibilityReport: compatibility.report
- )
- return RDEPUBTextChapterRenderRequest(
- context: context,
- style: style,
- pageSize: pageSize,
- layoutConfig: layoutConfig
- )
- }
-
- static func normalizeReadingAttributes(
- in attributedString: NSMutableAttributedString,
- style: RDEPUBTextRenderStyle,
- layoutConfig: RDEPUBTextLayoutConfig = .default
- ) {
- let fullRange = NSRange(location: 0, length: attributedString.length)
-
- var blockIndex = 0
- let sourceText = attributedString.string as NSString
-
- attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
-
- let sourceFont = attributes[.font] as? UIFont
- let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
-
- let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
- paragraph.lineSpacing = style.lineSpacing
- paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
- normalizeAlignedParagraphIndent(paragraph)
-
- paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
-
- var updatedAttributes = attributes
- updatedAttributes[.font] = normalizedFont
- updatedAttributes[.paragraphStyle] = paragraph
-
- if let textColor = style.textColor {
- updatedAttributes[.foregroundColor] = textColor
- }
-
- RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
-
- let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
- .flatMap(NSRangeFromString)
- .flatMap { $0.length > 0 ? $0 : nil }
- let paragraphRange = sourceText.length > 0
- ? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
- : range
-
- updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
- updatedAttributes[.rdPageBlockIndex] = blockIndex
-
- if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
- updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
- }
-
- if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
- updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
- }
-
- if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
- updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
- }
-
- if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
- updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
- }
-
- attributedString.setAttributes(updatedAttributes, range: range)
- blockIndex += 1
- }
- }
-
- static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
-
- let fallbackAttributes: [NSAttributedString.Key: Any] = [
- .font: style.font,
- .paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
- .foregroundColor: style.textColor ?? UIColor.black
- ]
- return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
- }
-
- static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
- let style = NSMutableParagraphStyle()
- style.lineSpacing = lineSpacing
- style.paragraphSpacing = max(6, lineSpacing / 2)
- return style
- }
-
- private static func normalizeAlignedParagraphIndent(_ paragraph: NSMutableParagraphStyle) {
- guard paragraph.alignment == .right || paragraph.alignment == .center else {
- return
- }
- paragraph.firstLineHeadIndent = 0
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift
deleted file mode 100644
index 1fe50e7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift
+++ /dev/null
@@ -1,117 +0,0 @@
-import UIKit
-
-struct RDEPUBTypesettingInput {
-
- var href: String
-
- var spineIndex: Int?
-
- var title: String
-
- var rawHTML: String
-
- var baseURL: URL?
-
- var style: RDEPUBTextRenderStyle
-
- var resourceResolver: RDEPUBResourceResolver?
-
- var contentLanguageCode: String?
-
- var pageSize: CGSize?
-
- var layoutConfig: RDEPUBTextLayoutConfig?
-}
-
-struct RDEPUBTypesettingOutput {
-
- var request: RDEPUBTextChapterRenderRequest
-
- var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
-
- var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
-}
-
-protocol RDEPUBTypesettingStage {
-
- func process(_ html: String, context: RDEPUBTypesettingInput) -> String
-}
-
-struct RDEPUBTextTypesetterPipeline {
-
- func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
-
- let htmlNormalizer = RDEPUBHTMLNormalizer()
- let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
- let cfiMarkerInjector = RDEPUBCFIMarkerInjector()
- let styleSheetComposer = RDEPUBStyleSheetComposer()
- let fontNormalizer = RDEPUBFontNormalizer()
- let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
- let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
-
- let normalizedHTML = cfiMarkerInjector.process(
- semanticMarkerInjector.process(
- htmlNormalizer.process(input.rawHTML, context: input),
- context: input
- ),
- context: input
- )
-
- let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
-
- let fontResults = fontNormalizer.registerEmbeddedFonts(
- html: normalizedHTML,
- inlinedCSS: styleSheetComposition.inlinedCSS,
- input: input
- )
-
- let compatibilityReport = Self.mergedCompatibilityReport(
- styleSheetComposition.compatibilityReport,
- fontResults: fontResults
- )
-
- let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
-
- let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
-
- let context = RDEPUBTextChapterContext(
- href: input.href,
- title: input.title,
- html: markedHTML,
- baseURL: input.baseURL,
- stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
- resourceDiagnostics: diagnostics,
- styleCompatibilityReport: compatibilityReport
- )
- let request = RDEPUBTextChapterRenderRequest(
- context: context,
- style: input.style,
- pageSize: input.pageSize,
- layoutConfig: input.layoutConfig
- )
- return RDEPUBTypesettingOutput(
- request: request,
- diagnostics: diagnostics,
- styleCompatibilityReport: compatibilityReport
- )
- }
-
- private static func mergedCompatibilityReport(
- _ report: RDEPUBCSSCompatibilityReport,
- fontResults: [RDEPUBFontRegistrationResult]
- ) -> RDEPUBCSSCompatibilityReport {
-
- let fontFailures = fontResults.compactMap { result -> String? in
- guard result.didRegister == false else { return nil }
- let family = result.descriptor.family ?? "unknown-family"
- let href = result.descriptor.href
- let reason = result.errorDescription ?? "Font registration failed"
- return "\(family) <\(href)>: \(reason)"
- }
- return RDEPUBCSSCompatibilityReport(
- unsupportedRules: report.unsupportedRules,
- normalizedRules: report.normalizedRules,
- fontFailures: report.fontFailures + fontFailures
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift
deleted file mode 100644
index 5e8103d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift
+++ /dev/null
@@ -1,24 +0,0 @@
-import UIKit
-
-final class RDEPUBNotePopupCoordinator {
-
- static func present(
- _ note: RDEPUBResolvedNote,
- from controller: UIViewController,
- onReturnToSource: (() -> Void)? = nil,
- onOpenNoteLocation: (() -> Void)? = nil
- ) {
- let popup = RDEPUBNotePopupViewController(
- note: note,
- onReturnToSource: onReturnToSource,
- onOpenNoteLocation: onOpenNoteLocation
- )
- let navigationController = UINavigationController(rootViewController: popup)
- navigationController.modalPresentationStyle = .pageSheet
- if let sheet = navigationController.sheetPresentationController {
- sheet.detents = [.medium(), .large()]
- sheet.prefersGrabberVisible = true
- }
- controller.present(navigationController, animated: true)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift
deleted file mode 100644
index 1f5df59..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift
+++ /dev/null
@@ -1,145 +0,0 @@
-import UIKit
-
-final class RDEPUBNotePopupViewController: UIViewController {
-
- private let note: RDEPUBResolvedNote
- private let onReturnToSource: (() -> Void)?
- private let onOpenNoteLocation: (() -> Void)?
-
- private let textView = UITextView()
- private let actionsStackView = UIStackView()
-
- init(
- note: RDEPUBResolvedNote,
- onReturnToSource: (() -> Void)? = nil,
- onOpenNoteLocation: (() -> Void)? = nil
- ) {
- self.note = note
- self.onReturnToSource = onReturnToSource
- self.onOpenNoteLocation = onOpenNoteLocation
- super.init(nibName: nil, bundle: nil)
- title = note.title ?? "注释"
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .systemBackground
- navigationItem.rightBarButtonItem = UIBarButtonItem(
- barButtonSystemItem: .done,
- target: self,
- action: #selector(close)
- )
-
- textView.translatesAutoresizingMaskIntoConstraints = false
- textView.isEditable = false
- textView.alwaysBounceVertical = true
- textView.backgroundColor = .clear
- textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
- textView.attributedText = attributedContent()
- textView.accessibilityIdentifier = "epub.reader.note.text"
- actionsStackView.translatesAutoresizingMaskIntoConstraints = false
- actionsStackView.axis = .horizontal
- actionsStackView.spacing = 12
- actionsStackView.distribution = .fillEqually
- actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
- configureActionButtons()
-
- view.addSubview(actionsStackView)
- view.addSubview(textView)
-
- NSLayoutConstraint.activate([
- actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
- actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
- actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
- actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
- textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
- textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- }
-
- private func configureActionButtons() {
- if note.sourceLocation != nil, onReturnToSource != nil {
- actionsStackView.addArrangedSubview(
- makeActionButton(title: "返回原文", action: #selector(returnToSource))
- )
- }
- if onOpenNoteLocation != nil {
- actionsStackView.addArrangedSubview(
- makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
- )
- }
- actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
- }
-
- private func makeActionButton(title: String, action: Selector) -> UIButton {
- let button = UIButton(type: .system)
- var configuration = UIButton.Configuration.filled()
- configuration.cornerStyle = .medium
- configuration.title = title
- configuration.baseBackgroundColor = .secondarySystemBackground
- configuration.baseForegroundColor = .label
- button.configuration = configuration
- if title == "返回原文" {
- button.accessibilityIdentifier = "epub.reader.note.return"
- } else if title == "打开注释原文" {
- button.accessibilityIdentifier = "epub.reader.note.open"
- }
- button.addTarget(self, action: action, for: .touchUpInside)
- return button
- }
-
- private func attributedContent() -> NSAttributedString {
- let html = """
-
-
-
-
-
- \(note.html)
-
- """
- guard let data = html.data(using: .utf8),
- let attributed = try? NSMutableAttributedString(
- data: data,
- options: [
- .documentType: NSAttributedString.DocumentType.html,
- .characterEncoding: String.Encoding.utf8.rawValue
- ],
- documentAttributes: nil
- ) else {
- return NSAttributedString(string: note.plainText)
- }
- return attributed
- }
-
- @objc private func close() {
- dismiss(animated: true)
- }
-
- @objc private func returnToSource() {
- dismiss(animated: true) { [onReturnToSource] in
- onReturnToSource?()
- }
- }
-
- @objc private func openNoteLocation() {
- dismiss(animated: true) { [onOpenNoteLocation] in
- onOpenNoteLocation?()
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift
deleted file mode 100644
index 73eed51..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-
-import UIKit
-
-final class RDEPUBAttachmentTooltipOverlayView: UIView {
-
- var onBackgroundTap: (() -> Void)?
-
- var tooltipView: RDEPUBAttachmentTooltipView? {
- subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
- }
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
-
- let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
- tapGesture.cancelsTouchesInView = false
- addGestureRecognizer(tapGesture)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- @objc
- private func handleTap(_ gesture: UITapGestureRecognizer) {
- let point = gesture.location(in: self)
- guard let tooltipView else {
- onBackgroundTap?()
- return
- }
- if !tooltipView.frame.contains(point) {
- onBackgroundTap?()
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift
deleted file mode 100644
index f82fd91..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift
+++ /dev/null
@@ -1,188 +0,0 @@
-
-import UIKit
-
-final class RDEPUBAttachmentTooltipView: UIView {
-
- enum ArrowPlacement {
-
- case top
-
- case bottom
- }
-
- private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
-
- private let arrowSize = CGSize(width: 20, height: 10)
-
- private let cornerRadius: CGFloat = 18
-
- private(set) var minimumArrowX: CGFloat = 28
-
- private var arrowTipX: CGFloat?
-
- private var arrowPlacement: ArrowPlacement = .bottom
-
- private let textLabel: UILabel = {
- let label = UILabel()
- label.numberOfLines = 0
- label.textColor = .white
- label.font = .systemFont(ofSize: 16, weight: .regular)
- label.lineBreakMode = .byWordWrapping
- return label
- }()
-
- private let shapeLayer = CAShapeLayer()
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- isOpaque = false
- layer.addSublayer(shapeLayer)
- addSubview(textLabel)
- accessibilityIdentifier = "epub.reader.attachment.tooltip"
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func layoutSubviews() {
- super.layoutSubviews()
- shapeLayer.frame = bounds
- shapeLayer.path = bubblePath(in: bounds).cgPath
- shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
-
- let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
- let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
- let labelFrame = bounds.inset(by: UIEdgeInsets(
- top: topInset,
- left: contentInsets.left,
- bottom: bottomInset,
- right: contentInsets.right
- ))
- textLabel.frame = labelFrame
- }
-
- func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
- arrowTipX = value
- arrowPlacement = placement
- setNeedsLayout()
- }
-
- func configure(text: String, maxWidth: CGFloat) {
- textLabel.text = text
- let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
- let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
- frame.size = CGSize(
- width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
- height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
- )
- setNeedsLayout()
- layoutIfNeeded()
- }
-
- private func bubblePath(in rect: CGRect) -> UIBezierPath {
- let bubbleRect: CGRect
- switch arrowPlacement {
- case .bottom:
- bubbleRect = CGRect(
- x: rect.minX,
- y: rect.minY,
- width: rect.width,
- height: rect.height - arrowSize.height
- )
- case .top:
- bubbleRect = CGRect(
- x: rect.minX,
- y: rect.minY + arrowSize.height,
- width: rect.width,
- height: rect.height - arrowSize.height
- )
- }
- let arrowMidX = min(
- max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
- bubbleRect.width - minimumArrowX
- )
- let arrowHalfWidth = arrowSize.width / 2
-
- let path = UIBezierPath()
- switch arrowPlacement {
- case .bottom:
- path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
- path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
- radius: cornerRadius,
- startAngle: -.pi / 2,
- endAngle: 0,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
- radius: cornerRadius,
- startAngle: 0,
- endAngle: .pi / 2,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
- path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
- path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
- path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
- radius: cornerRadius,
- startAngle: .pi / 2,
- endAngle: .pi,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
- radius: cornerRadius,
- startAngle: .pi,
- endAngle: -.pi / 2,
- clockwise: true
- )
- case .top:
- path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
- path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
- path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
- path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
- path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
- radius: cornerRadius,
- startAngle: -.pi / 2,
- endAngle: 0,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
- radius: cornerRadius,
- startAngle: 0,
- endAngle: .pi / 2,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
- radius: cornerRadius,
- startAngle: .pi / 2,
- endAngle: .pi,
- clockwise: true
- )
- path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
- path.addArc(
- withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
- radius: cornerRadius,
- startAngle: .pi,
- endAngle: -.pi / 2,
- clockwise: true
- )
- }
- path.close()
- return path
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift
deleted file mode 100644
index bb6ace2..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift
+++ /dev/null
@@ -1,241 +0,0 @@
-import UIKit
-
-final class RDEPUBImageViewController: UIViewController {
-
- struct Configuration {
- let image: UIImage
- let sourceRect: CGRect?
- let altText: String?
- let theme: RDEPUBReaderTheme
-
- init(image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil, theme: RDEPUBReaderTheme) {
- self.image = image
- self.sourceRect = sourceRect
- self.altText = altText
- self.theme = theme
- }
- }
-
- // MARK: - Properties
-
- private let configuration: Configuration
- private let scrollView = UIScrollView()
- private let imageView = UIImageView()
- private let closeButton = UIButton(type: .system)
- private let backgroundView = UIView()
-
- private var isShowingChrome = true
-
- // MARK: - Init
-
- init(configuration: Configuration) {
- self.configuration = configuration
- super.init(nibName: nil, bundle: nil)
- modalPresentationStyle = .fullScreen
- modalTransitionStyle = .crossDissolve
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- // MARK: - Lifecycle
-
- override func viewDidLoad() {
- super.viewDidLoad()
- setupBackground()
- setupScrollView()
- setupImageView()
- setupCloseButton()
- }
-
- override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- centerImage()
- }
-
- override var prefersStatusBarHidden: Bool { true }
-
- // MARK: - Setup
-
- private func setupBackground() {
- backgroundView.translatesAutoresizingMaskIntoConstraints = false
- backgroundView.backgroundColor = configuration.theme.imageViewerBackgroundColor
- view.addSubview(backgroundView)
- NSLayoutConstraint.activate([
- backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
- backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
- ])
-
- let tap = UITapGestureRecognizer(target: self, action: #selector(dismissViewer))
- backgroundView.addGestureRecognizer(tap)
- }
-
- private func setupScrollView() {
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.delegate = self
- scrollView.minimumZoomScale = 1.0
- scrollView.maximumZoomScale = 5.0
- scrollView.showsVerticalScrollIndicator = false
- scrollView.showsHorizontalScrollIndicator = false
- scrollView.alwaysBounceVertical = false
- scrollView.alwaysBounceHorizontal = false
- scrollView.decelerationRate = .fast
- view.addSubview(scrollView)
- NSLayoutConstraint.activate([
- scrollView.topAnchor.constraint(equalTo: view.topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
- ])
-
- let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissViewer))
- swipeDown.direction = .down
- scrollView.addGestureRecognizer(swipeDown)
- }
-
- private func setupImageView() {
- imageView.image = configuration.image
- imageView.contentMode = .scaleAspectFit
- imageView.isUserInteractionEnabled = true
- imageView.accessibilityLabel = configuration.altText
- imageView.isAccessibilityElement = configuration.altText != nil
- scrollView.addSubview(imageView)
-
- let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
- doubleTap.numberOfTapsRequired = 2
- imageView.addGestureRecognizer(doubleTap)
-
- let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleChrome))
- singleTap.numberOfTapsRequired = 1
- singleTap.require(toFail: doubleTap)
- imageView.addGestureRecognizer(singleTap)
- }
-
- private func setupCloseButton() {
- let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
- closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal)
- closeButton.tintColor = .white
- closeButton.backgroundColor = UIColor(white: 0.3, alpha: 0.6)
- closeButton.layer.cornerRadius = 16
- closeButton.clipsToBounds = true
- closeButton.addTarget(self, action: #selector(dismissViewer), for: .touchUpInside)
- closeButton.accessibilityLabel = NSLocalizedString("Close image viewer", comment: "Accessibility label for close button in image viewer")
-
- view.addSubview(closeButton)
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
- closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
- closeButton.widthAnchor.constraint(equalToConstant: 32),
- closeButton.heightAnchor.constraint(equalToConstant: 32)
- ])
- }
-
- // MARK: - Layout
-
- private func centerImage() {
- guard let image = imageView.image else { return }
- let boundsSize = scrollView.bounds.size
- guard boundsSize.width > 0, boundsSize.height > 0 else { return }
-
- let imageSize = image.size
- guard imageSize.width > 0, imageSize.height > 0 else { return }
-
- let widthRatio = boundsSize.width / imageSize.width
- let heightRatio = boundsSize.height / imageSize.height
- let fitScale = min(widthRatio, heightRatio)
-
- let fitWidth = imageSize.width * fitScale
- let fitHeight = imageSize.height * fitScale
-
- imageView.frame = CGRect(
- x: 0,
- y: 0,
- width: fitWidth,
- height: fitHeight
- )
-
- scrollView.contentSize = imageView.frame.size
-
- let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
- let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
- scrollView.contentInset = UIEdgeInsets(
- top: verticalInset,
- left: horizontalInset,
- bottom: verticalInset,
- right: horizontalInset
- )
- }
-
- // MARK: - Actions
-
- @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
- if scrollView.zoomScale > scrollView.minimumZoomScale {
- scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
- } else {
- let point = gesture.location(in: imageView)
- let zoomSize = CGSize(width: 100, height: 100)
- let zoomRect = CGRect(
- x: point.x - zoomSize.width / 2,
- y: point.y - zoomSize.height / 2,
- width: zoomSize.width,
- height: zoomSize.height
- )
- scrollView.zoom(to: zoomRect, animated: true)
- }
- }
-
- @objc private func toggleChrome() {
- isShowingChrome.toggle()
- UIView.animate(withDuration: 0.25) {
- self.closeButton.alpha = self.isShowingChrome ? 1.0 : 0.0
- }
- }
-
- @objc private func dismissViewer() {
- dismiss(animated: true)
- }
-}
-
-// MARK: - UIScrollViewDelegate
-
-extension RDEPUBImageViewController: UIScrollViewDelegate {
-
- func viewForZooming(in scrollView: UIScrollView) -> UIView? {
- imageView
- }
-
- func scrollViewDidZoom(_ scrollView: UIScrollView) {
- guard let image = imageView.image else { return }
- let boundsSize = scrollView.bounds.size
- let imageSize = image.size
- guard imageSize.width > 0, imageSize.height > 0 else { return }
-
- let widthRatio = boundsSize.width / imageSize.width
- let heightRatio = boundsSize.height / imageSize.height
- let fitScale = min(widthRatio, heightRatio)
-
- let fitWidth = imageSize.width * fitScale * scrollView.zoomScale
- let fitHeight = imageSize.height * fitScale * scrollView.zoomScale
-
- imageView.frame = CGRect(
- x: 0,
- y: 0,
- width: fitWidth,
- height: fitHeight
- )
-
- let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
- let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
- scrollView.contentInset = UIEdgeInsets(
- top: verticalInset,
- left: horizontalInset,
- bottom: verticalInset,
- right: horizontalInset
- )
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift
deleted file mode 100644
index d0dcf09..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import UIKit
-
-final class RDEPUBImageViewerCoordinator {
-
- private weak var presentingController: UIViewController?
- private let theme: RDEPUBReaderTheme
-
- init(presentingController: UIViewController, theme: RDEPUBReaderTheme) {
- self.presentingController = presentingController
- self.theme = theme
- }
-
- // MARK: - From Native Text Path (UIImage already resolved)
-
- func presentImage(_ image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil) {
- let config = RDEPUBImageViewController.Configuration(
- image: image,
- sourceRect: sourceRect,
- altText: altText,
- theme: theme
- )
- let viewerVC = RDEPUBImageViewController(configuration: config)
- presentingController?.present(viewerVC, animated: true)
- }
-
- // MARK: - From WebView Path (need to resolve from src URL)
-
- func presentImageFromWebView(
- src: String,
- baseHref: String?,
- resourceResolver: RDEPUBResourceResolver
- ) {
- guard let image = loadImage(src: src, baseHref: baseHref, resourceResolver: resourceResolver) else {
- return
- }
- presentImage(image)
- }
-
- // MARK: - Private
-
- private func loadImage(
- src: String,
- baseHref: String?,
- resourceResolver: RDEPUBResourceResolver
- ) -> UIImage? {
- let pathPart = src.components(separatedBy: "#").first ?? src
- guard !pathPart.isEmpty else { return nil }
-
- let fileURL: URL?
- if let baseHref {
- fileURL = resourceResolver.fileURL(forReference: pathPart, relativeToHref: baseHref)
- } else {
- let normalized = resourceResolver.normalizedHref(pathPart)
- fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) }
- }
-
- // 经统一资源入口读取:加密图片由 provider 解密,明文图片直读磁盘
- guard let fileURL, let data = resourceResolver.resourceData(at: fileURL) else { return nil }
- return UIImage(data: data)
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift
deleted file mode 100644
index c33ebf6..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift
+++ /dev/null
@@ -1,146 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol {
-
- var onShowTableOfContents: (() -> Void)?
-
- var onShowBookmarks: (() -> Void)?
-
- var onShowHighlights: (() -> Void)?
-
- var onAddHighlight: (() -> Void)?
-
- var onShowSettings: (() -> Void)?
-
- private let stackView: UIStackView = {
- let view = UIStackView()
- view.axis = .horizontal
- view.distribution = .fillEqually
- view.alignment = .fill
- view.spacing = 16
- return view
- }()
-
- private let chapterButton = RDEPUBReaderTintButton(type: .system)
-
- private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
-
- private let highlightsButton = RDEPUBReaderTintButton(type: .system)
-
- private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
-
- private let settingsButton = RDEPUBReaderTintButton(type: .system)
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.bottomToolbar"
-
- addSubview(stackView)
- stackView.translatesAutoresizingMaskIntoConstraints = false
- stackView.addArrangedSubview(chapterButton)
- stackView.addArrangedSubview(bookmarksButton)
- stackView.addArrangedSubview(highlightsButton)
- stackView.addArrangedSubview(addHighlightButton)
- stackView.addArrangedSubview(settingsButton)
-
- NSLayoutConstraint.activate([
- stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- stackView.topAnchor.constraint(equalTo: topAnchor),
- stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
- ])
-
- configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
- configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
- configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
- configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
- configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
- chapterButton.accessibilityIdentifier = "epub.reader.toc"
- bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks"
- highlightsButton.accessibilityIdentifier = "epub.reader.highlights"
- addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight"
- settingsButton.accessibilityIdentifier = "epub.reader.settings"
-
- [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
- button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
- }
-
- chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside)
- bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside)
- highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside)
- addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
- settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func lineFrame(in bounds: CGRect) -> CGRect {
- CGRect(x: 0, y: 0, width: bounds.width, height: 0.5)
- }
-
- override func apply(theme: RDEPUBReaderTheme) {
- super.apply(theme: theme)
- [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
- button.tintColor = .black
- button.setTitleColor(.black, for: .normal)
- }
- }
-
- func setAddHighlightEnabled(_ isEnabled: Bool) {
- addHighlightButton.isEnabled = isEnabled
- addHighlightButton.alpha = isEnabled ? 1 : 0.45
- }
-
- func setHighlightsEnabled(_ isEnabled: Bool) {
- highlightsButton.isEnabled = isEnabled
- highlightsButton.alpha = isEnabled ? 1 : 0.45
- }
-
- func setBookmarksEnabled(_ isEnabled: Bool) {
- bookmarksButton.isEnabled = isEnabled
- bookmarksButton.alpha = isEnabled ? 1 : 0.45
- }
-
- func updateVisibility(
- showsTableOfContents: Bool,
- allowsHighlights: Bool,
- showsSettingsPanel: Bool
- ) {
- chapterButton.isHidden = !showsTableOfContents
- bookmarksButton.isHidden = false
- highlightsButton.isHidden = !allowsHighlights
- addHighlightButton.isHidden = !allowsHighlights
- settingsButton.isHidden = !showsSettingsPanel
- }
-
- private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) {
- button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
- if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) {
- button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
- } else {
- button.setTitle(fallbackTitle, for: .normal)
- }
- }
-
- @objc private func chapterAction() {
- onShowTableOfContents?()
- }
-
- @objc private func bookmarksAction() {
- onShowBookmarks?()
- }
-
- @objc private func highlightsAction() {
- onShowHighlights?()
- }
-
- @objc private func highlightAction() {
- onAddHighlight?()
- }
-
- @objc private func settingsAction() {
- onShowSettings?()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift
deleted file mode 100644
index 184128a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderChapterListController: UITableViewController {
-
- var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
-
- private let items: [RDEPUBReaderTableOfContentsItem]
-
- private let currentItem: RDEPUBReaderTableOfContentsItem?
-
- private let theme: RDEPUBReaderTheme
-
- init(
- items: [RDEPUBReaderTableOfContentsItem],
- currentItem: RDEPUBReaderTableOfContentsItem?,
- theme: RDEPUBReaderTheme
- ) {
- self.items = items
- self.currentItem = currentItem
- self.theme = theme
- super.init(style: .plain)
- title = "目录"
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.accessibilityIdentifier = "epub.reader.toc.panel"
- tableView.accessibilityIdentifier = "epub.reader.toc.table"
- tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
- tableView.tableFooterView = UIView(frame: .zero)
- tableView.backgroundColor = theme.contentBackgroundColor
- navigationController?.navigationBar.tintColor = theme.toolControlTextColor
- navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
- navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
- navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
- }
-
- override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- items.count
- }
-
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let item = items[indexPath.row]
- let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
- cell.backgroundColor = theme.contentBackgroundColor
- cell.textLabel?.numberOfLines = 2
- cell.textLabel?.text = item.title
- cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
- cell.indentationLevel = item.depth
- cell.indentationWidth = 18
- return cell
- }
-
- override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- onSelectItem?(items[indexPath.row])
- }
-
- private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
- guard let currentItem else { return false }
- return currentItem.href == item.href
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift
deleted file mode 100644
index 8ba839b..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift
+++ /dev/null
@@ -1,79 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
- hideAttachmentTooltipIfNeeded()
-
- let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
- overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- overlay.onBackgroundTap = { [weak self, weak overlay] in
- guard let self, let overlay else { return }
- self.dismissAttachmentTooltipOverlay(overlay)
- }
-
- let tooltip = RDEPUBAttachmentTooltipView()
- tooltip.alpha = 0
- let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
- tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
-
- let anchorRect = sourceView.convert(sourceRect, to: view)
- let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
- let anchorPoint = CGPoint(
- x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
- y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
- )
- let verticalSpacing: CGFloat = 6
- let tooltipSize = tooltip.frame.size
- let idealX = anchorPoint.x - tooltipSize.width / 2
- let minX = horizontalPadding
- let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
- let originX = min(max(idealX, minX), maxX)
- let topSafeY = view.safeAreaInsets.top + 12
- let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
- let availableSpaceAbove = anchorRect.minY - topSafeY
- let availableSpaceBelow = bottomSafeY - anchorRect.maxY
- let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
- let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
- let originY: CGFloat
- switch tooltipPlacement {
- case .bottom:
- originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
- case .top:
- originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
- }
- let arrowTipX = min(
- max(anchorPoint.x - originX, tooltip.minimumArrowX),
- tooltipSize.width - tooltip.minimumArrowX
- )
-
- tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
- tooltip.frame.origin = CGPoint(x: originX, y: originY)
- overlay.addSubview(tooltip)
- view.addSubview(overlay)
-
- UIView.animate(withDuration: 0.2) {
- tooltip.alpha = 1
- }
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
- guard let self, let overlay else { return }
- self.dismissAttachmentTooltipOverlay(overlay)
- }
- }
-
- private func hideAttachmentTooltipIfNeeded() {
- view.subviews
- .compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
- .forEach { $0.removeFromSuperview() }
- }
-
- private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
- UIView.animate(withDuration: 0.18, animations: {
- overlay.tooltipView?.alpha = 0
- }, completion: { _ in
- overlay.removeFromSuperview()
- })
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift
deleted file mode 100644
index 54bb779..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift
+++ /dev/null
@@ -1,185 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
- guard readerView.currentPage >= 0,
- activePages.indices.contains(readerView.currentPage) else {
- return
- }
-
- guard readerView.pageContentView(pageNum: readerView.currentPage) === contentView 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) {
- if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
- return
- }
-
- 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)
- openExternalURLIfAllowed(url)
- }
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
- #if DEBUG
- print("EPUB JS Error: \(message)")
- #endif
- }
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
- guard let publication else { return }
- let baseHref = contentView.currentHref
- let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
- coordinator.presentImageFromWebView(src: src, baseHref: baseHref, resourceResolver: publication.resourceResolver)
- }
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
- guard !altText.isEmpty else { return }
- let rect = sourceRect ?? .zero
- let sourcePoint = rect.isNull ? CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) : CGPoint(x: rect.midX, y: rect.midY)
- presentAttachmentTooltip(text: altText, sourceView: contentView, sourceRect: rect, sourcePoint: sourcePoint)
- }
-}
-
-extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
-
- func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
- guard let selection else {
- updateCurrentSelection(nil)
- return
- }
- updateCurrentSelection(selection)
- }
-
- func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
- RDEpubReaderTapDebug.log(
- "ReaderController.textContentTap",
- "delegate received reader tap from contentView=\(RDEpubReaderTapDebug.describe(contentView)) point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)"
- )
- readerView.handleContentTap(at: point, in: contentView)
- }
-
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
- selection: RDEPUBSelection?
- ) {
- handleSelectionMenuAction(action, selection: selection ?? currentSelection)
- contentView.clearSelection()
- }
-
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didActivateAttachmentText text: String,
- sourceRect: CGRect,
- sourcePoint: CGPoint
- ) {
- presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
- }
-
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didActivateImage image: UIImage,
- sourceRect: CGRect,
- altText: String?
- ) {
- let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
- coordinator.presentImage(image, sourceRect: sourceRect, altText: altText)
- }
-
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didRequestHighlightActions highlight: RDEPUBHighlight,
- sourceRect: CGRect
- ) {
- runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
- }
-
- private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
- guard let publication else { return false }
- let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
- let sourceLocation = currentVisibleLocation()
- let sourceCFI = sourceLocation?.cfi
- let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
- guard let note = resolver.resolveInternalLink(
- sourceHref: sourceHref,
- sourceCFI: sourceCFI,
- targetLocation: location,
- sourceLocation: sourceLocation,
- relativeToSpineIndex: fromSpineIndex
- ) else {
- return false
- }
-
- RDEPUBNotePopupCoordinator.present(
- note,
- from: self,
- onReturnToSource: { [weak self] in
- guard let self, let sourceLocation = note.sourceLocation else { return }
- self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
- },
- onOpenNoteLocation: { [weak self] in
- guard let self else { return }
- self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
- }
- )
- return true
- }
-
- private func navigateToLocation(
- _ location: RDEPUBLocation,
- relativeToSpineIndex spineIndex: Int?,
- animated: Bool
- ) {
- guard let pageNumber = readingSession?.queueNavigation(
- to: location,
- relativeToSpineIndex: spineIndex,
- bookIdentifier: currentBookIdentifier
- ) else {
- return
- }
- readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
- }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift
deleted file mode 100644
index 862b259..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift
+++ /dev/null
@@ -1,464 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate {
-
- public func numberOfPages(in readerView: RDEpubReaderView) -> Int {
- effectiveNumberOfPages
- }
-
- public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
- pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
- }
-
- public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
- pageIdentifier(readerView: readerView, pageNum: index)
- }
-
- public func readerView(
- _ readerView: RDEpubReaderView,
- shouldPreloadPageAt index: Int
- ) -> Bool {
- // 试读墙由宿主提供唯一 UIView,不允许预加载创建另一容器并抢走它。
- index != trialWallPageIndex
- }
-
- public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
- topToolView(readerView: readerView)
- }
-
- public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? {
- bottomToolView(readerView: readerView)
- }
-
- public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int {
- readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
- }
-
- public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView {
- // 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图
- if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
- return makeTrialWallPageView(reusableView: containerView)
- }
-
- if readerContext.bookPageMap != nil {
- _ = runtime.prepareOnDemandChapter(
- forAbsolutePageNumber: pageNum + 1,
- allowSynchronousLoad: false
- )
- var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
- let shouldAllowSynchronousFallback = (
- readerView.currentPage < 0 || readerView.currentPage == pageNum
- ) && !readerView.isPageCurlTransitioning
- if resolvedPage == nil, shouldAllowSynchronousFallback {
- _ = runtime.prepareOnDemandChapter(
- forAbsolutePageNumber: pageNum + 1,
- allowSynchronousLoad: true
- )
- resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
- }
- if let resolvedPage {
- let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
- contentView.delegate = self
- readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
- contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.configure(
- page: resolvedPage.page,
- pageNumber: pageNum + 1,
- totalPages: pageCountOfReaderView(readerView: readerView),
- configuration: configuration,
- chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
- chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
- highlights: textHighlights(for: resolvedPage.page),
- searchState: searchState(for: resolvedPage.page),
- displayCache: textDisplayCache
- )
- return contentView
- }
-
- let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
- contentView.delegate = self
- readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
- contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.configureLoading(
- pageNumber: pageNum + 1,
- totalPages: pageCountOfReaderView(readerView: readerView),
- configuration: configuration
- )
- return contentView
- }
-
- if let textBook, let page = textBook.page(at: pageNum + 1) {
- let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
- contentView.delegate = self
- readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
- contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
- guard let readerView, let contentView else { return }
- readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
- }
- contentView.configure(
- page: page,
- pageNumber: pageNum + 1,
- totalPages: textBook.pages.count,
- configuration: configuration,
- chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
- chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
- highlights: textHighlights(for: page),
- searchState: searchState(for: page),
- displayCache: textDisplayCache
- )
- 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: RDEpubReaderView, pageNum: Int) -> String? {
- if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
- return NSStringFromClass(RDEPUBTrialWallContainerView.self)
- }
- return (textBook == nil && readerContext.bookPageMap == 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 searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
- guard let globalSearchState = searchState else { return nil }
-
- let matches: [RDEPUBSearchMatch]
- let currentMatchIndex: Int?
-
- if let chapterData = chapterData(for: page),
- let resolvedState = resolvedSearchState(
- for: page,
- chapterData: chapterData,
- globalSearchState: globalSearchState
- ) {
- matches = resolvedState.matches
- currentMatchIndex = resolvedState.currentMatchIndex
- } else {
- matches = globalSearchState.matches.filter { searchMatch in
- searchMatchBelongsToPage(searchMatch, page: page)
- }
- currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
- matches.firstIndex(of: currentMatch)
- }
- }
- guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
- return globalSearchState.matches.isEmpty ? globalSearchState : nil
- }
- return RDEPUBSearchState(
- keyword: globalSearchState.keyword,
- matches: matches,
- currentMatchIndex: currentMatchIndex
- )
- }
-
- private func resolvedSearchState(
- for page: RDEPUBTextPage,
- chapterData: RDEPUBChapterData,
- globalSearchState: RDEPUBSearchState
- ) -> RDEPUBSearchState? {
- let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedKeyword.isEmpty else {
- return globalSearchState.matches.isEmpty ? globalSearchState : nil
- }
-
- let normalizedHref = normalizedPageHref(for: page)
- let exactMatches = exactChapterSearchMatches(
- in: chapterData,
- keyword: normalizedKeyword,
- normalizedHref: normalizedHref
- )
- let pageMatches = exactMatches.filter { match in
- guard let rangeLocation = match.rangeLocation else { return false }
- let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
- return NSIntersectionRange(range, page.contentRange).length > 0
- }
-
- let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
- let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
- pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
- }
-
- guard !pageMatches.isEmpty || currentMatchIndex != nil else {
- return globalSearchState.matches.isEmpty ? globalSearchState : nil
- }
-
- return RDEPUBSearchState(
- keyword: globalSearchState.keyword,
- matches: pageMatches,
- currentMatchIndex: currentMatchIndex
- )
- }
-
- private func exactChapterSearchMatches(
- in chapterData: RDEPUBChapterData,
- keyword: String,
- normalizedHref: String
- ) -> [RDEPUBSearchMatch] {
- let source = chapterData.attributedContent.string as NSString
- let fullLength = source.length
- guard fullLength > 0 else { return [] }
-
- var matches: [RDEPUBSearchMatch] = []
- var localMatchIndex = 0
- var searchRange = NSRange(location: 0, length: fullLength)
-
- while searchRange.length > 0 {
- let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else { break }
-
- let progressionDenominator = max(fullLength - 1, 1)
- let progression = Double(foundRange.location) / Double(progressionDenominator)
- matches.append(
- RDEPUBSearchMatch(
- href: normalizedHref,
- progression: progression,
- previewText: previewText(in: source, matchRange: foundRange),
- localMatchIndex: localMatchIndex,
- rangeLocation: foundRange.location,
- rangeLength: max(foundRange.length, 1),
- rangeAnchor: chapterData.rangeAnchor(for: foundRange)
- )
- )
-
- localMatchIndex += 1
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- if nextLocation >= fullLength {
- break
- }
- searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
- }
-
- return matches
- }
-
- private func previewText(in text: NSString, matchRange: NSRange) -> String {
- let previewRadius = 12
- let start = max(matchRange.location - previewRadius, 0)
- let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
- let range = NSRange(location: start, length: max(end - start, 0))
- return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
- if let textBook,
- let chapterData = textBook.chapterData(for: page.href) {
- return chapterData
- }
-
- guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
- return nil
- }
- return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
- }
-
- private func makeChapterData(
- from runtimeChapter: RDEPUBRuntimeChapter,
- chapterIndex: Int
- ) -> RDEPUBChapterData {
- let textChapter = RDEPUBTextChapter(
- chapterIndex: chapterIndex,
- spineIndex: runtimeChapter.spineIndex,
- href: runtimeChapter.href,
- title: runtimeChapter.title,
- attributedContent: runtimeChapter.typesetAttributedString,
- fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
- cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
- pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
- pages: runtimeChapter.pages
- )
- return RDEPUBChapterData(
- chapter: textChapter,
- indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
- )
- }
-
- private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
- if let textBook,
- let chapterData = textBook.chapterData(for: page.href),
- let range = chapterData.absoluteRange(for: searchMatch) {
- return NSIntersectionRange(range, page.contentRange).length > 0
- }
-
- let pageHref = normalizedPageHref(for: page)
- let matchHref = normalizedSearchHref(searchMatch.href)
- guard pageHref == matchHref else { return false }
-
- guard let rangeLocation = searchMatch.rangeLocation else {
- return false
- }
- let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
- return NSIntersectionRange(range, page.contentRange).length > 0
- }
-
- private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
- guard let publication else { return page.href }
- return publication.resourceResolver.normalizedHref(page.href) ?? page.href
- }
-
- private func normalizedSearchHref(_ href: String) -> String {
- guard let publication else { return href }
- return publication.resourceResolver.normalizedHref(href) ?? href
- }
-
- func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
- readerContext.textChapterData(forNormalizedHref: href)
- }
-
- public func topToolView(readerView: RDEpubReaderView) -> UIView? {
- topToolView
- }
-
- public func bottomToolView(readerView: RDEpubReaderView) -> UIView? {
- bottomToolView
- }
-
- public func pageNum(readerView: RDEpubReaderView, pageNum: Int) {
- RDEPUBMemoryProbe.logPageTurn()
- readerContext.markUserNavigationActivity()
- updateCurrentSelection(nil)
- reconcileTextPaginationSizeIfNeeded(for: pageNum)
-
- guard !isRepaginating else { return }
-
- let previousCurrentPage = readerView.currentPage
- let wasPageCurlTransitioning = readerView.isPageCurlTransitioning
- runtime.applyPendingFullPageMapIfNeeded()
- if wasPageCurlTransitioning {
- DispatchQueue.main.async { [weak self, weak readerView] in
- guard let self, let readerView, !readerView.isPageCurlTransitioning else { return }
- self.runtime.applyPendingFullPageMapIfNeeded()
- }
- }
- let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
- if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
- return
- }
-
- if readerContext.bookPageMap != nil {
- _ = runtime.prepareOnDemandChapter(
- forAbsolutePageNumber: effectivePageNum + 1,
- allowSynchronousLoad: false
- )
- runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
- }
-
- // 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI)
- if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex {
- delegate?.epubReaderDidReachTrialWall(self)
- return
- }
-
- let totalPages = pageCountOfReaderView(readerView: readerView)
- if totalPages > 0, effectivePageNum == totalPages - 1 {
- delegate?.epubReaderDidReachEnd(self)
- }
-
- if (textBook != nil || readerContext.bookPageMap != nil),
- let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
- persist(location: location)
- synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
-
- runtime.locationCoordinator.recordPageChangeIfNeeded()
- return
- }
-
- if let location = fallbackLocation(for: effectivePageNum) {
- persist(location: location)
- }
-
- runtime.locationCoordinator.recordPageChangeIfNeeded()
- }
-
- public func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) {
- _ = isLandscape
- runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
- }
-
- private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
- guard textBook != nil || readerContext.bookPageMap != nil,
- !isRepaginating,
- !isReconcilingTextPaginationSize,
- pageNum >= 0,
- let lastTextPaginationPageSize else {
- return
- }
-
- let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
- guard resolvedSize.width > 0,
- resolvedSize.height > 0 else {
- return
- }
-
- let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
- || abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
- guard sizeChanged else {
- return
- }
-
- isReconcilingTextPaginationSize = true
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
- guard let self else { return }
- self.isReconcilingTextPaginationSize = false
- guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
-
- let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
- guard currentSize.width > 0, currentSize.height > 0 else { return }
- guard let previousPageSize = self.lastTextPaginationPageSize else { return }
- let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
- || abs(currentSize.height - previousPageSize.height) > 0.5
- guard stillChanged else { return }
- self.repaginatePreservingCurrentLocation()
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift
deleted file mode 100644
index bdebaf4..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- func openExternalURLIfAllowed(_ url: URL) {
- guard shouldAllowExternalURL(url) else { return }
- if configuration.requiresExternalLinkConfirmation {
- presentExternalLinkConfirmation(for: url)
- } else {
- UIApplication.shared.open(url, options: [:], completionHandler: nil)
- }
- }
-
- private func shouldAllowExternalURL(_ url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased() else { return false }
- if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
- return false
- }
- return configuration.allowedExternalURLSchemes.contains(scheme)
- }
-
- private func presentExternalLinkConfirmation(for url: URL) {
- let alert = UIAlertController(
- title: "打开外部链接",
- message: url.absoluteString,
- preferredStyle: .alert
- )
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
- alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
- UIApplication.shared.open(url, options: [:], completionHandler: nil)
- })
- present(alert, animated: true)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift
deleted file mode 100644
index 7ff047e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift
+++ /dev/null
@@ -1,319 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
- if let publication,
- let bookPageMap = readerContext.bookPageMap,
- let spineIndex = readerContext.normalizedSpineIndex(for: location),
- let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- let localPageIndex = resolvedLocalPageIndex(
- for: normalizedLocation,
- spineIndex: spineIndex,
- fallbackEntry: entry,
- rangeInfo: rangeInfo
- ) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
- return bookPageMap.absolutePageIndex(
- spineIndex: spineIndex,
- localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
- ).map { $0 + 1 }
- }
-
- if let textBook {
- let normalizedLocation = publication?.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- ) ?? location
-
- if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) {
- return pageNumber
- }
-
- if let anchor = normalizedLocation.rangeAnchor?.start {
- if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
- return page + 1
- }
- }
-
- if let publication {
- return textBook.pageNumber(
- for: normalizedLocation,
- resolver: publication.resourceResolver,
- bookIdentifier: currentBookIdentifier
- )
- }
- // External text books have no resolver; hrefs match verbatim.
- return textBook.chapterData(for: normalizedLocation.href)?
- .pageNumber(for: normalizedLocation)
- }
-
- return readingSession?.queueNavigation(
- to: location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- )
- }
-
- func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
- if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
- let startOffset = resolvedPage.page.pageStartOffset
- let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
- let chapterData = makeRuntimeChapterData(from: resolvedPage)
- let location = chapterData.location(
- for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
- bookIdentifier: currentBookIdentifier
- )
- if let publication {
- return publication.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- }
- return location
- }
-
- guard let textBook,
- let page = textBook.page(at: pageNumber) else {
- return nil
- }
-
- let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
- ?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
- guard let location else { return nil }
-
- if let publication {
- return publication.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- }
- return location
- }
-
- func currentVisibleRangeInfo() -> String? {
- guard let pageNumber = currentPageNumber else {
- return nil
- }
-
- if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
- let startOffset = resolvedPage.page.pageStartOffset
- let endOffset = max(startOffset + 1, resolvedPage.page.pageEndOffset + 1)
- return RDEPUBTextOffsetRangeInfo(
- href: resolvedPage.chapter.href,
- start: startOffset,
- end: endOffset
- ).jsonString()
- }
-
- guard let textBook,
- let page = textBook.page(at: pageNumber) else {
- return nil
- }
-
- let startOffset = page.pageStartOffset
- let endOffset = max(startOffset + 1, page.pageEndOffset + 1)
- return RDEPUBTextOffsetRangeInfo(
- href: page.href,
- start: startOffset,
- end: endOffset
- ).jsonString()
- }
-
- func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
- if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
- readingSession?.updateReadingContext(
- pageNumber: pageNumber,
- location: location,
- spineIndex: resolvedPage.page.spineIndex,
- chapterIndex: resolvedPage.chapterIndex,
- bookIdentifier: currentBookIdentifier
- )
- return
- }
-
- guard let textBook,
- let page = textBook.page(at: pageNumber) else {
- return
- }
-
- readingSession?.updateReadingContext(
- pageNumber: pageNumber,
- location: location,
- spineIndex: page.spineIndex,
- chapterIndex: page.chapterIndex,
- bookIdentifier: currentBookIdentifier
- )
- }
-
- func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
- let chapters = textBook.chapterInfos
- 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)
- }
-
- func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int {
- if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
- return decodedRangeInfo.start
- }
- // Native text anchors carry the exact UTF-16 chapter offset and remain
- // stable across font/layout changes. Prefer them over CFI recovery,
- // which may temporarily resolve to the chapter's first marker while a
- // new CFI map is still being prepared.
- if let anchor = location.rangeAnchor?.start {
- return anchor.chapterOffset
- }
- if let spineIndex = readerContext.normalizedSpineIndex(for: location),
- let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
- let cfi = primaryLocationCFI(for: location),
- let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: cfi) {
- return offset
- }
- if let rawCFI = primaryLocationCFI(for: location),
- let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI),
- let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
- return cfiOffset
- }
- if let fragment = location.fragment,
- let offset = fallbackEntry.fragmentOffsets[fragment] {
- return offset
- }
- return 0
- }
-
- func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
- guard pageCount > 1 else { return 0 }
- return min(
- pageCount - 1,
- max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
- )
- }
-
- private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
- runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
- }
-
- private func resolvedLocalPageIndex(
- for location: RDEPUBLocation,
- spineIndex: Int,
- fallbackEntry: RDEPUBBookPageMapEntry,
- rangeInfo: String? = nil
- ) -> Int? {
- let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo)
-
- if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
- let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
- return pageIndex
- }
-
- if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
- return summary.pageRanges.firstIndex {
- let range = $0.nsRange
- return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
- }
- }
-
- return nil
- }
-
- private func primaryLocationCFI(for location: RDEPUBLocation) -> String? {
- if let rangeCFI = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI) {
- return rangeCFI.start.rawValue
- }
- return location.cfi
- }
-
- private func pageNumberFromRangeInfo(
- _ rangeInfo: String?,
- in textBook: RDEPUBTextBook,
- matching location: RDEPUBLocation
- ) -> Int? {
- guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location),
- let chapterData = chapterData(in: textBook, for: decodedRangeInfo),
- let page = chapterData.page(containing: decodedRangeInfo.start) else {
- return nil
- }
- return page.absolutePageIndex + 1
- }
-
- private func decodedRangeInfo(
- from rangeInfo: String?,
- matching location: RDEPUBLocation
- ) -> RDEPUBTextOffsetRangeInfo? {
- guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
- normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else {
- return nil
- }
- return decodedRangeInfo
- }
-
- private func chapterData(
- in textBook: RDEPUBTextBook,
- for rangeInfo: RDEPUBTextOffsetRangeInfo
- ) -> RDEPUBChapterData? {
- if let chapterData = textBook.chapterData(for: rangeInfo.href) {
- return chapterData
- }
-
- let targetHref = normalizedHref(rangeInfo.href)
- if let chapterData = textBook.chapterData(for: targetHref) {
- return chapterData
- }
-
- guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else {
- return nil
- }
- return textBook.chapterData(for: chapter.href)
- }
-
- private func normalizedHref(_ href: String) -> String {
- publication?.resourceResolver.normalizedHref(href)
- ?? href.components(separatedBy: "#").first
- ?? href
- }
-
- private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
- var bestID: String?
- var bestOffset = Int.min
- for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
- bestOffset = fragmentOffset
- bestID = fragmentID
- }
- return bestID
- }
-
- private func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
- let textChapter = RDEPUBTextChapter(
- chapterIndex: resolvedPage.chapterIndex,
- spineIndex: resolvedPage.chapter.spineIndex,
- href: resolvedPage.chapter.href,
- title: resolvedPage.chapter.title,
- attributedContent: resolvedPage.chapter.typesetAttributedString,
- fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
- cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
- pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
- pages: resolvedPage.chapter.pages
- )
- return RDEPUBChapterData(
- chapter: textChapter,
- indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift
deleted file mode 100644
index 32dd893..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import UIKit
-
-/// 朝向锁定支持。
-///
-/// 依据 `publication.metadata.orientation`(来源 OPF rendition:orientation,
-/// 或兜底的 iBooks display-options)返回受限的支持朝向,并在书籍打开后主动请求转向。
-/// 未声明朝向(nil / .auto)时不干预,跟随系统与容器。
-///
-/// 注意:`supportedInterfaceOrientations` 只有当阅读器是当前决定朝向的控制器时才生效
-/// (通常需宿主保证它被 present 或位于导航栈顶;若被包在自定义容器里,容器需转发该值)。
-extension RDEPUBReaderController {
-
- /// 由 EPUB 元数据推导的受限支持朝向;nil = 不限制(跟随系统)
- var epubSupportedOrientationMask: UIInterfaceOrientationMask? {
- switch publication?.metadata.orientation {
- case .portrait:
- return .portrait
- case .landscape:
- return .landscape
- case .auto, .none:
- return nil
- }
- }
-
- public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
- epubSupportedOrientationMask ?? super.supportedInterfaceOrientations
- }
-
- public override var shouldAutorotate: Bool {
- // 已锁定单一朝向时不自动旋转
- epubSupportedOrientationMask == nil
- }
-
- /// 打开书籍后按元数据主动转向(iOS 16+ 用 requestGeometryUpdate,旧系统回退到刷新支持朝向)。
- /// 由加载完成流程调用。
- func applyOrientationLockIfNeeded() {
- guard let mask = epubSupportedOrientationMask else { return }
- if #available(iOS 16.0, *) {
- setNeedsUpdateOfSupportedInterfaceOrientations()
- guard let scene = view.window?.windowScene else { return }
- scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
- } else {
- let target: UIInterfaceOrientation = (mask == .landscape) ? .landscapeRight : .portrait
- UIDevice.current.setValue(target.rawValue, forKey: "orientation")
- UIViewController.attemptRotationToDeviceOrientation()
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift
deleted file mode 100644
index afe8d50..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift
+++ /dev/null
@@ -1,168 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- public func reloadBook() {
- runtime.reloadBook()
- }
-
- public func go(to location: RDEPUBLocation) {
- guard publication != nil else { return }
- _ = runtime.go(to: location)
- }
-
- @discardableResult
- public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
- runtime.go(toPageNumber: pageNumber, animated: animated)
- }
-
- public func clearSelection() {
- runtime.clearSelection()
- }
-
- public func bookmark(withID id: String) -> RDEPUBBookmark? {
- runtime.bookmark(withID: id)
- }
-
- public func highlight(withID id: String) -> RDEPUBHighlight? {
- runtime.highlight(withID: id)
- }
-
- public func nativeTextSemanticSummary() -> String? {
- let resolvedPage: RDEPUBTextPage?
- if let textBook {
- resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first
- } else if readerContext.bookPageMap != nil {
- let absolutePageIndex = max(readerView.currentPage, 0)
- _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1)
- resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page
- } else {
- resolvedPage = nil
- }
-
- guard let page = resolvedPage 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? {
- runtime.addHighlight(from: selection, color: color, note: note)
- }
-
- @discardableResult
- public func addAnnotation(
- from selection: RDEPUBSelection? = nil,
- style: RDEPUBHighlightStyle,
- color: String = "#F8E16C",
- note: String? = nil
- ) -> RDEPUBHighlight? {
- runtime.addAnnotation(from: selection, style: style, color: color, note: note)
- }
-
- @discardableResult
- public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
- runtime.upsertHighlight(highlight)
- }
-
- @discardableResult
- public func removeHighlight(id: String) -> RDEPUBHighlight? {
- runtime.removeHighlight(id: id)
- }
-
- @discardableResult
- public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
- runtime.updateHighlightNote(id: id, note: note)
- }
-
- @discardableResult
- public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
- runtime.go(toHighlightID: id, animated: animated)
- }
-
- public func removeAllHighlights() {
- runtime.removeAllHighlights()
- }
-
- @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)
- }
-
- @discardableResult
- public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
- runtime.addBookmark(note: note)
- }
-
- @discardableResult
- public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
- runtime.toggleBookmark(note: note)
- }
-
- @discardableResult
- public func removeBookmark(id: String) -> RDEPUBBookmark? {
- runtime.removeBookmark(id: id)
- }
-
- @discardableResult
- public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
- runtime.go(toBookmarkID: id, animated: animated)
- }
-
- public func search(keyword: String) {
- runtime.search(keyword: keyword)
- }
-
- @discardableResult
- public func searchNext() -> Bool {
- runtime.searchNext()
- }
-
- @discardableResult
- public func searchPrevious() -> Bool {
- runtime.searchPrevious()
- }
-
- public func clearSearch() {
- runtime.clearSearch()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift
deleted file mode 100644
index a07576e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift
+++ /dev/null
@@ -1,86 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
- readerContext.currentLayoutContext()
- }
-
- func currentPreferences() -> RDEPUBPreferences {
- readerContext.currentPreferences()
- }
-
- func currentTextPageSize() -> CGSize {
- readerContext.currentTextPageSize()
- }
-
- func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
- readerContext.currentTextRenderStyle()
- }
-
- func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
- readerContext.currentTextLayoutConfig(pageSize: pageSize)
- }
-
- func resolvedTextRenderer() -> RDEPUBTextRenderer {
- readerContext.resolvedTextRenderer()
- }
-
- 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
- }
-
- 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)
- let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
- forPageNumber: pageIndex + 1,
- spineIndex: page.spineIndex
- )
- return currentPreferences().renderRequest(
- for: page,
- publication: publication,
- viewportSize: currentLayoutContext().viewportSize,
- targetLocation: pendingLocation,
- targetHighlightRangeInfo: pendingHighlightRangeInfo,
- highlights: highlights(for: page),
- searchPresentation: searchPresentation(for: page)
- )
- }
-
- func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
- guard activePages.indices.contains(pageIndex) else { return nil }
- return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
- }
-
- 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
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift
deleted file mode 100644
index 2fa6d66..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift
+++ /dev/null
@@ -1,241 +0,0 @@
-
-import UIKit
-
-extension RDEPUBReaderController {
-
- func applyReaderViewConfiguration() {
- let resolvedDirection = resolvedPageDirection()
- let presentationDidChange = readerView.currentDisplayType != configuration.displayType
- || readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
- || readerView.pageDirection != resolvedDirection
- let preservedLocation = presentationDidChange
- ? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
- : nil
- view.backgroundColor = configuration.theme.contentBackgroundColor
- readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
- readerView.pageDirection = resolvedDirection
- updateReaderChrome()
- if presentationDidChange {
- readerView.switchReaderDisplayType(configuration.displayType)
- if let preservedLocation {
- _ = restoreReadingLocation(preservedLocation)
- }
- }
- }
-
- func startInitialLoadIfNeeded() {
- runtime.startInitialLoadIfNeeded()
- }
-
- func loadPublication() {
- runtime.loadPublication()
- }
-
- func applyParsedPublication(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- bookIdentifier: String,
- restoreLocation: RDEPUBLocation?,
- bookmarks: [RDEPUBBookmark],
- highlights: [RDEPUBHighlight]
- ) {
- runtime.applyParsedPublication(
- parser: parser,
- publication: publication,
- bookIdentifier: bookIdentifier,
- restoreLocation: restoreLocation,
- bookmarks: bookmarks,
- highlights: highlights
- )
- }
-
- func paginatePublication(restoreLocation: RDEPUBLocation?) {
- runtime.paginatePublication(restoreLocation: restoreLocation)
- }
-
- func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
- runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
- }
-
- func applyPaginationSnapshot(
- _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
- restoreLocation: RDEPUBLocation?
- ) {
- runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
- }
-
- func finishPagination(restoreLocation: RDEPUBLocation?) {
- runtime.finishPagination(restoreLocation: restoreLocation)
- }
-
- func repaginatePreservingCurrentLocation() {
- runtime.repaginatePreservingCurrentLocation()
- }
-
- @discardableResult
- func restoreReadingLocation(
- _ location: RDEPUBLocation,
- animated: Bool = false,
- targetHighlightRangeInfo: String? = nil
- ) -> Bool {
- runtime.restoreReadingLocation(
- location,
- animated: animated,
- targetHighlightRangeInfo: targetHighlightRangeInfo
- )
- }
-
- func currentVisibleLocation() -> RDEPUBLocation? {
- runtime.currentVisibleLocation()
- }
-
- func persistenceLocation() -> RDEPUBLocation? {
- readerContext.persistenceLocation()
- }
-
- func persist(location: RDEPUBLocation) {
- readerContext.persist(location: location)
- }
-
- func updateCurrentSelection(_ selection: RDEPUBSelection?) {
- runtime.annotationCoordinator.updateCurrentSelection(selection)
- }
-
- func scopedSelection(
- _ selection: RDEPUBSelection,
- relativeToSpineIndex spineIndex: Int?
- ) -> RDEPUBSelection? {
- runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
- }
-
- func refreshVisibleContentPreservingLocation() {
- runtime.refreshVisibleContentPreservingLocation()
- }
-
- func rebuildExternalTextBook() {
- runtime.rebuildExternalTextBook()
- }
-
- func updateReaderChrome() {
- runtime.updateReaderChrome()
- }
-
- func presentBookmarksManager() {
- runtime.presentBookmarksManager()
- }
-
- func presentHighlightsManager() {
- runtime.presentHighlightsManager()
- }
-
- func presentAnnotationCreation() {
- runtime.presentAnnotationCreation()
- }
-
- func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
- runtime.handleSelectionMenuAction(action, selection: selection)
- }
-
- func presentSettings() {
- runtime.presentSettings()
- }
-
- func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
- var nextConfiguration = configuration
- update(&nextConfiguration)
- configuration = nextConfiguration
- }
-
- func setScreenBrightness(_ brightness: CGFloat) {
- currentBrightness = max(0, min(1, brightness))
- persistReaderSettingsIfNeeded()
- }
-
- func persistReaderSettingsIfNeeded() {
- let settings = RDEPUBReaderSettings.capture(
- configuration: configuration,
- brightness: currentBrightness
- )
- persistence?.saveReaderSettings(settings)
- }
-
- func requiresRepagination(
- from oldConfiguration: RDEPUBReaderConfiguration,
- to newConfiguration: RDEPUBReaderConfiguration
- ) -> Bool {
- oldConfiguration.fontSize != newConfiguration.fontSize ||
- oldConfiguration.fontChoice != newConfiguration.fontChoice ||
- oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
- oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
- oldConfiguration.columnGap != newConfiguration.columnGap ||
- oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
- oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
- oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
- oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode ||
- oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
- }
-
- func requiresVisibleRefresh(
- from oldConfiguration: RDEPUBReaderConfiguration,
- to newConfiguration: RDEPUBReaderConfiguration
- ) -> Bool {
- oldConfiguration.theme != newConfiguration.theme ||
- oldConfiguration.darkImageAdjustmentEnabled != newConfiguration.darkImageAdjustmentEnabled ||
- oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
- }
-
- func presentTableOfContents() {
- runtime.presentTableOfContents()
- }
-
- func handleBackAction() {
- runtime.handleBackAction()
- }
-
- func handle(error: Error) {
- isRepaginating = false
- hideLoading()
- readerContext.clearActiveSnapshot()
- textBook = nil
- readerContext.bookPageMap = nil
- readerView.reloadData()
- errorLabel.text = error.localizedDescription
- errorLabel.isHidden = false
- delegate?.epubReader(self, didFailWithError: error)
- }
-
- func showLoading() {
- errorLabel.isHidden = true
- loadingIndicator.startAnimating()
- }
-
- func hideLoading() {
- loadingIndicator.stopAnimating()
- }
-
- func currentViewportSignature() -> RDEPUBViewportSignature? {
- runtime.currentViewportSignature()
- }
-
- func handleViewportChangeIfNeeded(
- reason: RDEPUBViewportChangeReason,
- viewportSignature: RDEPUBViewportSignature? = nil
- ) {
- runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
- }
-
- func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
- runtime.searchPresentation(for: page)
- }
-
- private func resolvedPageDirection() -> RDEpubReaderView.PageDirection {
- publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
- }
-}
-
-extension RDEPUBReaderController: UIGestureRecognizerDelegate {
-
- public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
- true
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift
deleted file mode 100644
index 8c38a63..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift
+++ /dev/null
@@ -1,109 +0,0 @@
-
-import Foundation
-
-extension RDEPUBReaderController {
-
- func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
- let items = flattenedTableOfContentsItems(
- from: publication?.tableOfContents ?? [],
- includePageNumbers: false
- )
- guard !items.isEmpty else { return nil }
-
- guard let publication,
- let currentLocation = currentVisibleLocation(),
- let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
- return nil
- }
-
- if let textBook,
- let chapterData = textBook.chapterData(
- for: currentLocation,
- resolver: publication.resourceResolver,
- bookIdentifier: currentBookIdentifier
- ),
- let tocItem = chapterData.primaryTableOfContentsItem(
- from: publication.tableOfContents,
- normalizer: { publication.resourceResolver.normalizedHref($0) }
- ) {
- return items.last { $0.href == tocItem.href } ?? items.last {
- guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
- return false
- }
- return normalizedItemHref == normalizedCurrentHref
- }
- }
-
- return items.last { item in
- guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
- return false
- }
- return normalizedItemHref == normalizedCurrentHref
- }
- }
-
- func flattenedTableOfContentsItems(
- from items: [EPUBTableOfContentsItem],
- depth: Int = 0,
- includePageNumbers: Bool = true
- ) -> [RDEPUBReaderTableOfContentsItem] {
- items.flatMap { item in
- let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
- let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
-
- let current = RDEPUBReaderTableOfContentsItem(
- title: item.title,
- href: item.href,
- depth: depth,
- pageNumber: pageNumber
- )
- return [current] + flattenedTableOfContentsItems(
- from: item.children,
- depth: depth + 1,
- includePageNumbers: includePageNumbers
- )
- }
- }
-
- private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? {
- if let publication,
- let bookPageMap = readerContext.bookPageMap,
- let spineIndex = readerContext.normalizedSpineIndex(for: location),
- let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- location,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- let localPageIndex: Int
- if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
- let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
- localPageIndex = summary.pageRanges.firstIndex {
- let range = $0.nsRange
- return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
- } ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
- } else {
- localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
- }
- return bookPageMap.absolutePageIndex(
- spineIndex: spineIndex,
- localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
- ).map { $0 + 1 }
- }
-
- if let textBook, let publication,
- let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- location,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- return chapterData.pageNumber(for: normalizedLocation)
- ?? textBook.pageNumber(
- for: location,
- resolver: publication.resourceResolver,
- bookIdentifier: currentBookIdentifier
- )
- }
-
- return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift
deleted file mode 100644
index 69c67e8..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-import UIKit
-
-/// 试读墙支持。
-///
-/// 设计:`pageCountOfReaderView` 仍返回原始内容页数(内容页显示的总页数不受影响),
-/// 仅 `numberOfPages(in:)` 在启用试读墙时 +1;试读墙页固定位于最后一个可读页之后。
-/// 两种形态统一覆盖:
-/// - readoor 独立试读 epub(整本都是试读章节):readableChapterCount 传 nil,墙追加在全书末尾;
-/// - 整本 epub + 按章限制:readableChapterCount 传 N,超出章节页不展示(仅 bookPageMap 路径)。
-extension RDEPUBReaderController {
-
- /// 试读墙视图(首次访问经 delegate 获取并缓存)
- var trialWallView: UIView? {
- if let cached = cachedTrialWallView {
- return cached
- }
- guard configuration.trialPolicy != nil else { return nil }
- let view = delegate?.epubReaderTrialWallView(self)
- cachedTrialWallView = view
- return view
- }
-
- /// 是否启用试读墙:配置了试读策略且宿主提供了墙视图
- var isTrialWallEnabled: Bool {
- configuration.trialPolicy != nil && trialWallView != nil
- }
-
- /// 原始可读内容页数(不含试读墙页)。
- /// readableChapterCount 指定且存在 bookPageMap 时按章截断,否则为全部内容页。
- var readableContentPageCount: Int {
- let rawCount = pageCountOfReaderView(readerView: readerView)
- guard let policy = configuration.trialPolicy,
- let readableChapterCount = policy.readableChapterCount,
- readableChapterCount > 0,
- let pageMap = readerContext.bookPageMap else {
- return rawCount
- }
- // 第一个不可读章节(spineIndex >= readableChapterCount)的起始绝对页 = 可读页数
- let firstLockedStart = pageMap.entries
- .first { $0.spineIndex >= readableChapterCount }?
- .absolutePageStart
- return firstLockedStart ?? rawCount
- }
-
- /// 试读墙页的绝对索引(0-based);未启用时为 nil
- var trialWallPageIndex: Int? {
- guard isTrialWallEnabled else { return nil }
- return readableContentPageCount
- }
-
- /// 供 RDEpubReaderView 使用的有效页数:启用试读墙时为可读页数 + 1(墙页)
- var effectiveNumberOfPages: Int {
- guard isTrialWallEnabled else {
- return pageCountOfReaderView(readerView: readerView)
- }
- return readableContentPageCount + 1
- }
-
- /// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图)
- func makeTrialWallPageView(reusableView: UIView?) -> UIView {
- let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
- container.setWallView(trialWallView)
- return container
- }
-}
-
-/// 试读墙页容器:让宿主墙视图铺满整页
-final class RDEPUBTrialWallContainerView: UIView, RDEpubReaderCachePolicyProviding {
-
- /// 宿主只提供一份试读墙 View。预加载会同时构建多个页容器,若缓存该容器会
- /// 把同一 View 从实际可见页移走,最终表现为横竖翻页的试读墙白屏。
- var shouldAvoidReaderPageCaching: Bool { true }
-
- private weak var wallView: UIView?
-
- func setWallView(_ view: UIView?) {
- // 即使 View 对象相同,只要它已被其他复用容器接管,也必须重新挂回当前页。
- if wallView === view, view?.superview === self {
- return
- }
- wallView?.removeFromSuperview()
- guard let view else { return }
- view.translatesAutoresizingMaskIntoConstraints = false
- addSubview(view)
- NSLayoutConstraint.activate([
- view.leadingAnchor.constraint(equalTo: leadingAnchor),
- view.trailingAnchor.constraint(equalTo: trailingAnchor),
- view.topAnchor.constraint(equalTo: topAnchor),
- view.bottomAnchor.constraint(equalTo: bottomAnchor)
- ])
- wallView = view
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift
deleted file mode 100644
index b508359..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift
+++ /dev/null
@@ -1,503 +0,0 @@
-import UIKit
-
-public final class RDEPUBReaderController: UIViewController {
-
- public weak var delegate: RDEPUBReaderDelegate?
-
- public var configuration: RDEPUBReaderConfiguration {
- didSet {
- // M-09: Apply all configuration side effects even before view is loaded,
- // but UI-dependent actions only after view is loaded.
- readerContext.configuration = configuration
- guard isViewLoaded else { return }
- applyWebViewDebugPolicy()
- persistReaderSettingsIfNeeded()
- 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 internal(set) var currentSelection: RDEPUBSelection? {
- get { readerContext.currentSelection }
- set { readerContext.currentSelection = newValue }
- }
-
- public var highlights: [RDEPUBHighlight] {
- activeHighlights
- }
-
- public var bookmarks: [RDEPUBBookmark] {
- activeBookmarks
- }
-
- public var annotations: [RDEPUBAnnotation] {
- let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
- return merged.sorted { $0.createdAt < $1.createdAt }
- }
-
- 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()
- }
-
- let epubURL: URL
-
- let persistence: RDEPUBReaderPersistence?
-
- let dependencies: RDEPUBReaderDependencies
-
- let readerView = RDEpubReaderView()
-
- let loadingIndicator: UIActivityIndicatorView = {
- UIActivityIndicatorView(style: .large)
- }()
-
- let errorLabel: UILabel = {
- let label = UILabel()
- label.numberOfLines = 0
- label.textAlignment = .center
- label.textColor = .darkGray
- label.isHidden = true
- return label
- }()
-
- let paginationHostView = UIView()
-
- /// Chapter-level shared display content/layouter for text pages
- /// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
- let textDisplayCache = RDEPUBChapterDisplayContentCache()
-
- lazy var readerContext = RDEPUBReaderContext(controller: self)
-
- var parser: RDEPUBParser? {
- get { readerContext.parser }
- set { readerContext.parser = newValue }
- }
-
- var publication: RDEPUBPublication? {
- get { readerContext.publication }
- set { readerContext.publication = newValue }
- }
-
- var readingSession: RDEPUBReadingSession? {
- get { readerContext.readingSession }
- set { readerContext.readingSession = newValue }
- }
-
- var textBook: RDEPUBTextBook? {
- get { readerContext.textBook }
- set { readerContext.textBook = newValue }
- }
-
- var activePages: [EPUBPage] {
- readerContext.activePages
- }
-
- var activeChapters: [EPUBChapterInfo] {
- readerContext.activeChapters
- }
-
- var activeBookmarks: [RDEPUBBookmark] {
- get { readerContext.activeBookmarks }
- set { readerContext.activeBookmarks = newValue }
- }
-
- var activeHighlights: [RDEPUBHighlight] {
- get { readerContext.activeHighlights }
- set { readerContext.activeHighlights = newValue }
- }
-
- lazy var topToolView = runtime.makeTopToolView()
-
- lazy var bottomToolView = runtime.makeBottomToolView()
-
- lazy var searchBarView = RDEPUBReaderSearchBarView()
-
- /// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
- var cachedTrialWallView: UIView?
-
- /// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本
- /// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。
- private var navigationBarHiddenBeforeReader: Bool?
-
- private(set) var isSearchBarVisible = false
-
- var currentBookIdentifier: String? {
- get { readerContext.currentBookIdentifier }
- set { readerContext.currentBookIdentifier = newValue }
- }
-
- var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
-
- var currentBrightness: CGFloat {
- get { readerContext.currentBrightness }
- set { readerContext.currentBrightness = newValue }
- }
-
- var didStartInitialLoad: Bool {
- get { readerContext.didStartInitialLoad }
- set { readerContext.didStartInitialLoad = newValue }
- }
-
- var isRepaginating: Bool {
- get { readerContext.isRepaginating }
- set { readerContext.isRepaginating = newValue }
- }
-
- var lastTextPaginationPageSize: CGSize? {
- get { readerContext.lastTextPaginationPageSize }
- set { readerContext.lastTextPaginationPageSize = newValue }
- }
-
- var isReconcilingTextPaginationSize = false
-
- var paginationToken: UUID {
- get { readerContext.paginationToken }
- set { readerContext.paginationToken = newValue }
- }
-
- var searchState: RDEPUBSearchState? {
- get { readerContext.searchState }
- set { readerContext.searchState = newValue }
- }
-
- var isExternalTextBook: Bool {
- get { readerContext.isExternalTextBook }
- set { readerContext.isExternalTextBook = newValue }
- }
-
- var textFileURL: URL? {
- get { readerContext.textFileURL }
- set { readerContext.textFileURL = newValue }
- }
-
- lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
-
- lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
-
- public init(
- epubURL: URL,
- configuration: RDEPUBReaderConfiguration = .default,
- persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
- dependencies: RDEPUBReaderDependencies = .live
- ) {
- let persistedSettings = persistence?.loadReaderSettings()
- self.epubURL = epubURL
- self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
- self.persistence = persistence
- self.dependencies = dependencies
- let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
- super.init(nibName: nil, bundle: nil)
-
- readerContext.dependencies = dependencies
- readerContext.configuration = self.configuration
- readerContext.epubURL = epubURL
- readerContext.persistence = persistence
- self.currentBrightness = brightness
- applyWebViewDebugPolicy()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- private func applyWebViewDebugPolicy() {
- RDEPUBWebViewDebug.applyDebugPolicy(
- inspectableEnabled: configuration.allowsInspectableWebViews,
- verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
- )
- }
-
- public convenience init(
- textBook: RDEPUBTextBook,
- bookIdentifier: String,
- title: String,
- textFileURL: URL? = nil,
- configuration: RDEPUBReaderConfiguration = .default,
- persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
- dependencies: RDEPUBReaderDependencies = .live
- ) {
- let persistedSettings = persistence?.loadReaderSettings()
- let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
-
- self.init(
- epubURL: URL(string: "about:blank")!,
- configuration: resolvedConfig,
- persistence: persistence,
- dependencies: dependencies
- )
- self.isExternalTextBook = true
- self.textBook = textBook
- self.textFileURL = textFileURL
- self.currentBookIdentifier = bookIdentifier
- self.title = title
- self.didStartInitialLoad = true
- }
-
- public override func viewDidLoad() {
- super.viewDidLoad()
- currentBrightness = currentBrightness
- readerAssemblyCoordinator.assembleInterface()
- readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
- readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
- self?.handleToolViewVisibilityChanged(isVisible: isVisible)
- }
- }
-
- public override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- if navigationBarHiddenBeforeReader == nil {
- navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
- }
- navigationController?.setNavigationBarHidden(true, animated: animated)
- navigationController?.interactivePopGestureRecognizer?.delegate = self
- navigationController?.interactivePopGestureRecognizer?.isEnabled = true
- }
-
- public override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- if let navigationBarHiddenBeforeReader {
- navigationController?.setNavigationBarHidden(
- navigationBarHiddenBeforeReader,
- animated: animated
- )
- }
- }
-
- public override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- startInitialLoadIfNeeded()
- RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
- }
-
- public override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- runtime.viewportMonitor.viewDidLayoutSubviews()
- }
-
- public override func didReceiveMemoryWarning() {
- super.didReceiveMemoryWarning()
- runtime.handleMemoryWarning()
- textDisplayCache.removeAll()
- }
-
- public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
- super.viewWillTransition(to: size, with: coordinator)
- runtime.viewportMonitor.viewWillTransition(with: coordinator)
- coordinator.animate(alongsideTransition: nil) { _ in
- RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))")
- }
- }
-
- func showSearchBar() {
- guard !isSearchBarVisible else { return }
- isSearchBarVisible = true
- searchBarView.apply(theme: configuration.theme)
-
- searchBarView.onSearchSubmit = { [weak self] keyword in
- self?.searchBarView.showSearching()
- self?.runtime.search(keyword: keyword)
- self?.updateSearchCount()
- }
- searchBarView.onSearchTextChanged = { [weak self] keyword in
- guard let self else { return }
- let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- if normalizedKeyword.isEmpty {
- self.runtime.clearSearch()
- } else {
- self.searchBarView.showSearching()
- self.runtime.search(keyword: normalizedKeyword)
- }
- self.updateSearchCount()
- }
- searchBarView.onSearchPrevious = { [weak self] in
- _ = self?.runtime.searchPrevious()
- self?.updateSearchCount()
- }
- searchBarView.onSearchNext = { [weak self] in
- _ = self?.runtime.searchNext()
- self?.updateSearchCount()
- }
- searchBarView.onSelectMatch = { [weak self] matchIndex in
- guard let self else { return }
- let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
- self.updateSearchCount()
- if didNavigate {
- self.hideSearchBar(clearSearch: false)
- }
- }
- searchBarView.onClose = { [weak self] in
- self?.hideSearchBar(clearSearch: true)
- }
-
- if readerView.isShowToolView {
- installSearchBarView()
- }
- }
-
- private func installSearchBarView() {
- guard searchBarView.superview == nil else { return }
- searchBarView.apply(theme: configuration.theme)
-
- readerView.addSubview(searchBarView)
- readerView.searchBarView = searchBarView
- let bottomAnchor = bottomToolView.superview == nil
- ? readerView.bottomAnchor
- : bottomToolView.topAnchor
- NSLayoutConstraint.activate([
- searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
- searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
- searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
- searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
- ])
-
- searchBarView.alpha = 0
- searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
- UIView.animate(withDuration: 0.28) {
- self.searchBarView.alpha = 1
- self.searchBarView.presentedView.transform = .identity
- }
-
- if let keyword = searchState?.keyword, !keyword.isEmpty {
- searchBarView.restoreKeyword(keyword)
- updateSearchCount()
- } else {
- searchBarView.showNoResults()
- }
-
- DispatchQueue.main.async { [weak self] in
- self?.searchBarView.textField.becomeFirstResponder()
- }
- }
-
- func hideSearchBar(clearSearch: Bool = false) {
- guard isSearchBarVisible else { return }
- isSearchBarVisible = false
-
- searchBarView.textField.resignFirstResponder()
- UIView.animate(withDuration: 0.25, animations: {
- self.searchBarView.alpha = 0
- self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
- }) { _ in
- self.searchBarView.removeFromSuperview()
- self.searchBarView.alpha = 1
- self.searchBarView.presentedView.transform = .identity
- self.readerView.searchBarView = nil
- }
-
- if clearSearch {
- runtime.clearSearch()
- }
- }
-
- func updateSearchCount() {
- guard let searchState else {
- searchBarView.showNoResults()
- return
- }
- searchBarView.updateResults(
- sections: searchResultSections(for: searchState),
- keyword: searchState.keyword,
- currentMatchIndex: searchState.currentMatchIndex
- )
- }
-
- func handleToolViewVisibilityChanged(isVisible: Bool) {
- if isVisible {
- if isSearchBarVisible {
- installSearchBarView()
- } else if searchState != nil {
- showSearchBar()
- }
- } else {
- if isSearchBarVisible {
- hideSearchBar(clearSearch: false)
- }
- }
- }
-
-}
-
-private extension RDEPUBReaderController {
-
- func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
- let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
- searchSectionTitle(for: entry.element)
- })
-
- let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
- let title = searchSectionTitle(for: match)
- if titles.last != title, titles.contains(title) == false {
- titles.append(title)
- }
- }
-
- return orderedTitles.compactMap { title in
- guard let matches = groupedMatches[title] else { return nil }
- let items = matches.map { offset, match in
- RDEPUBReaderSearchSection.Item(
- matchIndex: offset,
- previewText: match.previewText,
- isCurrent: offset == searchState.currentMatchIndex
- )
- }
- return RDEPUBReaderSearchSection(title: title, items: items)
- }
- }
-
- func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
- guard let publication else {
- return match.href
- }
-
- let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
- let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
- if let tocItem = tocItems.last(where: {
- let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
- let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
- return normalizedItemHref == normalizedMatchHref
- }) {
- return tocItem.title
- }
-
- if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
- publication.spine.indices.contains(spineIndex) {
- return publication.spine[spineIndex].title
- }
-
- return normalizedMatchHref
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift
deleted file mode 100644
index 30f23fb..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-import UIKit
-
-public protocol RDEPUBReaderDelegate: AnyObject {
-
- func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
-
- func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
-
- func epubReaderDidReachEnd(_ reader: UIViewController)
-
- func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
-
- func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
-
- func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
-
- func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
-
- func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
-
- func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
-
- func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
-
- func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
-
- func epubReader(_ reader: UIViewController, didFailWithError error: Error)
-
- func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding)
-
- /// 提供试读墙视图(configuration.trialPolicy 非 nil 时生效)。
- /// 返回非 nil 时,阅读器在最后一个可读页之后追加一页展示该视图;返回 nil 则不追加试读墙。
- /// 视图的购买 / 登录按钮由宿主自行处理(可在点击后调用 reloadBook() 切换到完整 epub)。
- func epubReaderTrialWallView(_ reader: UIViewController) -> UIView?
-
- /// 用户翻到试读墙页时回调(宿主可在此触发购买引导 / 埋点等)。
- func epubReaderDidReachTrialWall(_ reader: UIViewController)
-}
-
-public extension RDEPUBReaderDelegate {
- func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
- func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
- func epubReaderDidReachEnd(_ reader: UIViewController) {}
- func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {}
- func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {}
- func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {}
- func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {}
- func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
- func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
- func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
- func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
- func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
- func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding) {}
- func epubReaderTrialWallView(_ reader: UIViewController) -> UIView? { nil }
- func epubReaderDidReachTrialWall(_ reader: UIViewController) {}
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift
deleted file mode 100644
index c127a2c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift
+++ /dev/null
@@ -1,351 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderHighlightsViewController: UITableViewController {
-
- var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
-
- var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
-
- var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
-
- private var highlights: [RDEPUBHighlight]
-
- private let theme: RDEPUBReaderTheme
-
- private let sectionTitleProvider: (RDEPUBHighlight) -> String?
-
- private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
-
- private var filteredHighlights: [RDEPUBHighlight] {
- switch filterControl.selectedSegmentIndex {
- case 1:
- return highlights.filter(\.hasNote)
- case 2:
- return highlights.filter { $0.style == .highlight }
- default:
- return highlights
- }
- }
-
- init(
- highlights: [RDEPUBHighlight],
- theme: RDEPUBReaderTheme,
- sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
- ) {
- self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
- self.theme = theme
- self.sectionTitleProvider = sectionTitleProvider
- super.init(style: .plain)
- title = "标注"
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.accessibilityIdentifier = "epub.reader.highlights.panel"
- tableView.accessibilityIdentifier = "epub.reader.highlights.table"
- tableView.tableFooterView = UIView(frame: .zero)
- tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
- configureFilterControl()
- applyTheme()
- updateEmptyState()
- }
-
- override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- filteredHighlights.count
- }
-
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cellIdentifier = "HighlightCell"
- let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
- let highlight = filteredHighlights[indexPath.row]
-
- cell.backgroundColor = theme.contentBackgroundColor
- cell.textLabel?.textColor = theme.contentTextColor
- cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
- cell.textLabel?.numberOfLines = 2
- cell.textLabel?.text = titleText(for: highlight)
-
- cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
- cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
- cell.detailTextLabel?.numberOfLines = 3
- cell.detailTextLabel?.text = detailText(for: highlight)
- cell.accessoryType = .disclosureIndicator
- return cell
- }
-
- override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- tableView.deselectRow(at: indexPath, animated: true)
- presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
- }
-
- private func configureFilterControl() {
- filterControl.selectedSegmentIndex = 0
- filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
- filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
- navigationItem.titleView = filterControl
- }
-
- private func applyTheme() {
- tableView.backgroundColor = theme.contentBackgroundColor
- navigationController?.navigationBar.tintColor = theme.toolControlTextColor
- navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
- navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
- navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
- }
-
- private func updateEmptyState() {
- guard filteredHighlights.isEmpty else {
- tableView.backgroundView = nil
- return
- }
-
- let label = UILabel()
- label.text = emptyStateText()
- label.textAlignment = .center
- label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
- label.numberOfLines = 0
- label.accessibilityIdentifier = "epub.reader.highlights.empty"
- tableView.backgroundView = label
- }
-
- @objc private func filterChangedAction() {
- tableView.reloadData()
- updateEmptyState()
- }
-
- private func emptyStateText() -> String {
- switch filterControl.selectedSegmentIndex {
- case 1:
- return "暂无批注"
- case 2:
- return "暂无划线"
- default:
- return "暂无标注"
- }
- }
-
- private func detailText(for highlight: RDEPUBHighlight) -> String {
- let style = styleDescription(for: highlight)
- let chapter = sectionTitleProvider(highlight)
- let note = normalizedNote(highlight.note)
- let parts = [style, chapter, note].compactMap { $0 }
- if !parts.isEmpty {
- return parts.joined(separator: "\n")
- }
- return highlight.location.href
- }
-
- private func titleText(for highlight: RDEPUBHighlight) -> String {
- let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
- if highlight.hasNote {
- return "批注: \(text)"
- }
- return text
- }
-
- private func styleDescription(for highlight: RDEPUBHighlight) -> String {
- switch highlight.style {
- case .highlight:
- return highlight.hasNote ? "高亮批注" : "高亮"
- case .underline:
- return highlight.hasNote ? "划线批注" : "划线"
- }
- }
-
- private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
- let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
- alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
- self?.onSelectHighlight?(highlight)
- })
- alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
- self?.presentNoteEditor(for: highlight)
- })
- alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
- self?.deleteHighlight(highlight)
- })
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
-
- if let popover = alert.popoverPresentationController,
- let cell = tableView.cellForRow(at: sourceIndexPath) {
- popover.sourceView = cell
- popover.sourceRect = cell.bounds
- }
-
- present(alert, animated: true)
- }
-
- private func presentNoteEditor(for highlight: RDEPUBHighlight) {
- let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
- alert.addTextField { textField in
- textField.placeholder = "输入批注内容"
- textField.text = highlight.note
- }
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
- alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
- guard let self,
- let note = alert?.textFields?.first?.text,
- let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
- return
- }
-
- var updated = highlight
- updated.note = self.normalizedNote(note)
- self.highlights[index] = updated
- self.tableView.reloadData()
- self.onUpdateHighlight?(updated)
- self.updateEmptyState()
- })
- present(alert, animated: true)
- }
-
- private func deleteHighlight(_ highlight: RDEPUBHighlight) {
- guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
- highlights.remove(at: index)
- tableView.reloadData()
- onDeleteHighlight?(highlight)
- updateEmptyState()
- }
-
- private func normalizedNote(_ note: String?) -> String? {
- let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? nil : trimmed
- }
-}
-
-final class RDEPUBReaderBookmarksViewController: UITableViewController {
-
- var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
-
- var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
-
- private var bookmarks: [RDEPUBBookmark]
-
- private let theme: RDEPUBReaderTheme
-
- private let dateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd HH:mm"
- return formatter
- }()
-
- init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
- self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
- self.theme = theme
- super.init(style: .plain)
- title = "书签"
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
- tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
- tableView.tableFooterView = UIView(frame: .zero)
- tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
- applyTheme()
- updateEmptyState()
- }
-
- override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- bookmarks.count
- }
-
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cellIdentifier = "BookmarkCell"
- let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
- let bookmark = bookmarks[indexPath.row]
-
- cell.backgroundColor = theme.contentBackgroundColor
- cell.textLabel?.textColor = theme.contentTextColor
- cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
- cell.textLabel?.numberOfLines = 2
- cell.textLabel?.text = titleText(for: bookmark)
-
- cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
- cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
- cell.detailTextLabel?.numberOfLines = 3
- cell.detailTextLabel?.text = detailText(for: bookmark)
- cell.accessoryType = .disclosureIndicator
- return cell
- }
-
- override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- tableView.deselectRow(at: indexPath, animated: true)
- presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
- }
-
- private func applyTheme() {
- tableView.backgroundColor = theme.contentBackgroundColor
- navigationController?.navigationBar.tintColor = theme.toolControlTextColor
- navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
- navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
- navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
- }
-
- private func updateEmptyState() {
- guard bookmarks.isEmpty == false else {
- let label = UILabel()
- label.text = "暂无书签"
- label.textAlignment = .center
- label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
- label.numberOfLines = 0
- label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
- tableView.backgroundView = label
- return
- }
- tableView.backgroundView = nil
- }
-
- private func titleText(for bookmark: RDEPUBBookmark) -> String {
- if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
- return chapterTitle
- }
- return bookmark.location.href
- }
-
- private func detailText(for bookmark: RDEPUBBookmark) -> String {
- let parts = [
- dateFormatter.string(from: bookmark.createdAt),
- bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
- bookmark.location.href
- ].compactMap { value -> String? in
- guard let value, !value.isEmpty else { return nil }
- return value
- }
- return parts.joined(separator: "\n")
- }
-
- private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
- let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
- alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
- self?.onSelectBookmark?(bookmark)
- })
- alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
- self?.deleteBookmark(bookmark)
- })
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
-
- if let popover = alert.popoverPresentationController,
- let cell = tableView.cellForRow(at: sourceIndexPath) {
- popover.sourceView = cell
- popover.sourceRect = cell.bounds
- }
-
- present(alert, animated: true)
- }
-
- private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
- guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
- bookmarks.remove(at: index)
- tableView.reloadData()
- onDeleteBookmark?(bookmark)
- updateEmptyState()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift
deleted file mode 100644
index c4e113d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift
+++ /dev/null
@@ -1,180 +0,0 @@
-import Foundation
-
-public protocol RDEPUBReaderPersistence: AnyObject {
-
- func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
-
- func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
-
- func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
-
- func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
-
- func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
-
- func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
-
- func loadReaderSettings() -> RDEPUBReaderSettings?
-
- func saveReaderSettings(_ settings: RDEPUBReaderSettings)
-}
-
-public extension RDEPUBReaderPersistence {
- func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
- #if DEBUG
- print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
- #endif
- return []
- }
-
- func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
- #if DEBUG
- print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
- #endif
- }
-
- func loadReaderSettings() -> RDEPUBReaderSettings? {
- #if DEBUG
- print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
- #endif
- return nil
- }
-
- func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
- #if DEBUG
- print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
- #endif
- }
-}
-
-public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
-
- private let defaults: UserDefaults
-
- private let locationPrefix: String
-
- private let bookmarksPrefix: String
-
- private let highlightsPrefix: String
-
- private let settingsKey: String
-
- public init(
- defaults: UserDefaults = .standard,
- locationPrefix: String = "ssreader.epub.location.",
- bookmarksPrefix: String = "ssreader.epub.bookmarks.",
- highlightsPrefix: String = "ssreader.epub.highlights.",
- settingsKey: String = "ssreader.epub.settings"
- ) {
- self.defaults = defaults
- self.locationPrefix = locationPrefix
- self.bookmarksPrefix = bookmarksPrefix
- self.highlightsPrefix = highlightsPrefix
- self.settingsKey = settingsKey
- }
-
- public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
- guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
- return nil
- }
- do {
- return try JSONDecoder().decode(RDEPUBLocation.self, from: data)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)")
- #endif
- return nil
- }
- }
-
- public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
- do {
- let data = try JSONEncoder().encode(location)
- defaults.set(data, forKey: locationPrefix + bookIdentifier)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)")
- #endif
- }
- }
-
- public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
- guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
- return []
- }
- do {
- return try JSONDecoder().decode([RDEPUBBookmark].self, from: data)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)")
- #endif
- return []
- }
- }
-
- public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
- do {
- let data = try JSONEncoder().encode(bookmarks)
- defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
- #endif
- }
- }
-
- public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
- guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
- return []
- }
- do {
- return try JSONDecoder().decode([RDEPUBHighlight].self, from: data)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)")
- #endif
- return []
- }
- }
-
- public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
- do {
- let data = try JSONEncoder().encode(highlights)
- if data.count > 1_048_576 {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
- #endif
- }
- defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
- #endif
- }
- }
-
- public func loadReaderSettings() -> RDEPUBReaderSettings? {
- guard let data = defaults.data(forKey: settingsKey) else {
- return nil
- }
- do {
- return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)")
- #endif
- return nil
- }
- }
-
- public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
- do {
- let data = try JSONEncoder().encode(settings)
- defaults.set(data, forKey: settingsKey)
- } catch {
- #if DEBUG
- print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)")
- #endif
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift
deleted file mode 100644
index 3ac1c21..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift
+++ /dev/null
@@ -1,634 +0,0 @@
-import UIKit
-
-struct RDEPUBReaderSearchSection: Equatable {
-
- struct Item: Equatable {
-
- let matchIndex: Int
-
- let previewText: String
-
- let isCurrent: Bool
- }
-
- let title: String
-
- let items: [Item]
-}
-
-final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
-
- var onSearchSubmit: ((String) -> Void)?
-
- var onSearchTextChanged: ((String) -> Void)?
-
- var onSearchPrevious: (() -> Void)?
-
- var onSearchNext: (() -> Void)?
-
- var onSelectMatch: ((Int) -> Void)?
-
- var onClose: (() -> Void)?
-
- let textField: UITextField = {
- let field = UITextField()
- field.placeholder = "搜索"
- field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
- field.returnKeyType = .search
- field.autocorrectionType = .no
- field.autocapitalizationType = .none
- field.clearButtonMode = .whileEditing
- field.isAccessibilityElement = true
- if #available(iOS 13.0, *) {
- field.accessibilityTraits = .searchField
- }
- return field
- }()
-
- private let backgroundButton = UIButton(type: .custom)
-
- let panelView = UIView()
-
- private let grabberView = UIView()
-
- private let searchRowView = UIView()
-
- private let searchFieldContainer = UIView()
-
- private let searchIcon = UIImageView()
-
- private let searchFieldDivider = UIView()
-
- private let cancelButton = UIButton(type: .system)
-
- private let tableView = UITableView(frame: .zero, style: .plain)
-
- private let emptyStateLabel = UILabel()
-
- private let previousButton = RDEPUBReaderTintButton(type: .system)
-
- private let nextButton = RDEPUBReaderTintButton(type: .system)
-
- private let countLabel = UILabel()
-
- private var searchSections: [RDEPUBReaderSearchSection] = []
-
- private var keyword = ""
-
- private var currentMatchIndex: Int?
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.search.bar"
- shouldGroupAccessibilityChildren = false
- isAccessibilityElement = false
- setupSubviews()
- setupConstraints()
- setupActions()
- updateLegacyNavigationEnabled(false)
- showInitialState()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
- // Only claim touches that fall within the panelView or the
- // accessibility-only navigation buttons. Taps on the dimmed scrim
- // area pass through to the reader content below, allowing chrome
- // toggle taps to work.
- let panelPoint = convert(point, to: panelView)
- if panelView.point(inside: panelPoint, with: event) {
- return super.hitTest(point, with: event)
- }
- // Also claim touches on the accessibility-only navigation buttons
- if previousButton.frame.contains(point)
- || nextButton.frame.contains(point)
- || countLabel.frame.contains(point) {
- return super.hitTest(point, with: event)
- }
- return nil
- }
-
- override func lineFrame(in bounds: CGRect) -> CGRect {
- .zero
- }
-
- override func apply(theme: RDEPUBReaderTheme) {
- super.apply(theme: theme)
- let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
-
- let overlayColor = isDarkBackground
- ? UIColor(white: 0.12, alpha: 0.92)
- : UIColor(white: 0.08, alpha: 0.82)
- let panelColor = isDarkBackground
- ? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
- : UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
- let rowColor = isDarkBackground
- ? UIColor(white: 0.18, alpha: 1)
- : UIColor(white: 0.14, alpha: 0.96)
- let cardColor = isDarkBackground
- ? UIColor(white: 0.12, alpha: 1)
- : UIColor(white: 0.10, alpha: 0.98)
- let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
- let textColor = UIColor(white: 0.96, alpha: 1)
- let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
-
- backgroundColor = .clear
- backgroundButton.backgroundColor = overlayColor
- panelView.backgroundColor = panelColor
- grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
- searchRowView.backgroundColor = rowColor
- searchFieldContainer.backgroundColor = .clear
- searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
- searchIcon.tintColor = secondaryTextColor
- cancelButton.tintColor = textColor
- cancelButton.setTitleColor(textColor, for: .normal)
- textField.textColor = textColor
- textField.tintColor = UIColor.systemBlue
- textField.keyboardAppearance = isDarkBackground ? .dark : .default
- textField.attributedPlaceholder = NSAttributedString(
- string: "搜索",
- attributes: [.foregroundColor: secondaryTextColor]
- )
-
- emptyStateLabel.textColor = secondaryTextColor
- tableView.backgroundColor = .clear
- tableView.separatorStyle = .none
-
- previousButton.tintColor = textColor
- nextButton.tintColor = textColor
- countLabel.textColor = textColor
- countLabel.backgroundColor = .clear
-
- RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
- RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
- RDEPUBReaderSearchResultCell.primaryTextColor = textColor
- RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
- RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
-
- tableView.reloadData()
- }
-
- var presentedView: UIView {
- panelView
- }
-
- func updateMatchCount(current: Int, total: Int) {
- countLabel.text = "\(current)/\(total)"
- textField.accessibilityValue = "\(current)/\(total)"
- updateLegacyNavigationEnabled(total > 0)
- }
-
- func showNoResults() {
- currentMatchIndex = nil
- updateMatchCount(current: 0, total: 0)
- tableView.isHidden = true
- emptyStateLabel.isHidden = false
- emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
- }
-
- func showSearching() {
- updateMatchCount(current: 0, total: 0)
- tableView.isHidden = true
- emptyStateLabel.isHidden = false
- emptyStateLabel.text = "搜索中..."
- }
-
- func restoreKeyword(_ keyword: String) {
- textField.text = keyword
- self.keyword = keyword
- }
-
- func updateResults(
- sections: [RDEPUBReaderSearchSection],
- keyword: String,
- currentMatchIndex: Int?
- ) {
- self.keyword = keyword
- self.searchSections = sections
- self.currentMatchIndex = currentMatchIndex
-
- let total = sections.reduce(0) { $0 + $1.items.count }
- if let currentMatchIndex, total > 0 {
- updateMatchCount(current: currentMatchIndex + 1, total: total)
- } else {
- updateMatchCount(current: 0, total: total)
- }
-
- if total == 0 {
- showNoResults()
- return
- }
-
- emptyStateLabel.isHidden = true
- tableView.isHidden = false
- tableView.reloadData()
- scrollToCurrentMatchIfNeeded()
- }
-
- private func setupSubviews() {
- backgroundButton.translatesAutoresizingMaskIntoConstraints = false
- panelView.translatesAutoresizingMaskIntoConstraints = false
- grabberView.translatesAutoresizingMaskIntoConstraints = false
- searchRowView.translatesAutoresizingMaskIntoConstraints = false
- searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
- searchIcon.translatesAutoresizingMaskIntoConstraints = false
- textField.translatesAutoresizingMaskIntoConstraints = false
- searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
- cancelButton.translatesAutoresizingMaskIntoConstraints = false
- tableView.translatesAutoresizingMaskIntoConstraints = false
- emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
- previousButton.translatesAutoresizingMaskIntoConstraints = false
- nextButton.translatesAutoresizingMaskIntoConstraints = false
- countLabel.translatesAutoresizingMaskIntoConstraints = false
-
- addSubview(backgroundButton)
- addSubview(panelView)
-
- panelView.addSubview(grabberView)
- panelView.addSubview(searchRowView)
- panelView.addSubview(tableView)
- panelView.addSubview(emptyStateLabel)
-
- searchRowView.addSubview(searchFieldContainer)
- searchRowView.addSubview(searchFieldDivider)
- searchRowView.addSubview(cancelButton)
- searchFieldContainer.addSubview(searchIcon)
- searchFieldContainer.addSubview(textField)
-
- addSubview(previousButton)
- addSubview(nextButton)
- addSubview(countLabel)
-
- if #available(iOS 13.0, *) {
- searchIcon.image = UIImage(systemName: "magnifyingglass")
- previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
- nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
- } else {
- previousButton.setTitle("▲", for: .normal)
- nextButton.setTitle("▼", for: .normal)
- }
-
- searchIcon.contentMode = .scaleAspectFit
- searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
-
- panelView.layer.cornerRadius = 28
- panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
- panelView.clipsToBounds = true
-
- grabberView.layer.cornerRadius = 3
- searchRowView.layer.cornerRadius = 22
- searchFieldContainer.layer.cornerRadius = 22
- searchRowView.clipsToBounds = true
-
- cancelButton.setTitle("取消", for: .normal)
- cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
- cancelButton.accessibilityIdentifier = "epub.reader.search.close"
-
- emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
- emptyStateLabel.textAlignment = .center
- emptyStateLabel.numberOfLines = 0
-
- previousButton.accessibilityIdentifier = "epub.reader.search.previous"
- nextButton.accessibilityIdentifier = "epub.reader.search.next"
- countLabel.accessibilityIdentifier = "epub.reader.search.count"
- textField.accessibilityIdentifier = "epub.reader.search.field"
- // 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于
- // 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。
- previousButton.alpha = 0.02
- nextButton.alpha = 0.02
- countLabel.alpha = 0.02
-
- tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
- tableView.dataSource = self
- tableView.delegate = self
- tableView.showsVerticalScrollIndicator = false
- tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
- }
-
- private func setupConstraints() {
- NSLayoutConstraint.activate([
- backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
- backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
- backgroundButton.topAnchor.constraint(equalTo: topAnchor),
- backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
-
- panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
- panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
- panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
- panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
-
- grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
- grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
- grabberView.widthAnchor.constraint(equalToConstant: 92),
- grabberView.heightAnchor.constraint(equalToConstant: 6),
-
- searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
- searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
- searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
- searchRowView.heightAnchor.constraint(equalToConstant: 52),
-
- searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
- searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
- searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
-
- searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
- searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
- searchIcon.widthAnchor.constraint(equalToConstant: 24),
- searchIcon.heightAnchor.constraint(equalToConstant: 24),
-
- textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
- textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
- textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
- textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
-
- searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
- searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
- searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
- searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
-
- cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
- cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
- cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
-
- tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
- tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
- tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
- tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
-
- emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
- emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
- emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
-
- // 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图
- // 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏
- // 窗口拦截,永远到不了这些按钮。
- previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
- previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
- previousButton.widthAnchor.constraint(equalToConstant: 1),
- previousButton.heightAnchor.constraint(equalToConstant: 1),
-
- nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
- nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
- nextButton.widthAnchor.constraint(equalToConstant: 1),
- nextButton.heightAnchor.constraint(equalToConstant: 1),
-
- countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
- countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
- countLabel.widthAnchor.constraint(equalToConstant: 1),
- countLabel.heightAnchor.constraint(equalToConstant: 1)
- ])
- }
-
- private func setupActions() {
- textField.delegate = self
- textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
- textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
- previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
- nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
- cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
- backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
- }
-
- private func updateLegacyNavigationEnabled(_ enabled: Bool) {
- previousButton.isEnabled = enabled
- nextButton.isEnabled = enabled
- }
-
- private func showInitialState() {
- tableView.isHidden = true
- emptyStateLabel.isHidden = false
- emptyStateLabel.text = "输入关键词开始搜索"
- }
-
- private func scrollToCurrentMatchIfNeeded() {
- guard let currentMatchIndex else { return }
- for (sectionIndex, section) in searchSections.enumerated() {
- if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
- let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
- DispatchQueue.main.async { [weak self] in
- self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
- }
- return
- }
- }
- }
-
- private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
- searchSections[indexPath.section].items[indexPath.row]
- }
-
- @objc private func textFieldDidReturn() {
- let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard !keyword.isEmpty else { return }
- onSearchSubmit?(keyword)
- textField.resignFirstResponder()
- }
-
- @objc private func textFieldDidChange() {
- guard textField.markedTextRange == nil else { return }
- onSearchTextChanged?(textField.text ?? "")
- }
-
- @objc private func previousAction() {
- onSearchPrevious?()
- }
-
- @objc private func nextAction() {
- onSearchNext?()
- }
-
- @objc private func closeAction() {
- onClose?()
- }
-}
-
-extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
-
- func numberOfSections(in tableView: UITableView) -> Int {
- searchSections.count
- }
-
- func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- searchSections[section].items.count
- }
-
- func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(
- withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
- for: indexPath
- )
-
- guard let cell = cell as? RDEPUBReaderSearchResultCell else {
- return cell
- }
-
- let item = item(at: indexPath)
- cell.configure(
- previewText: item.previewText,
- keyword: keyword,
- isCurrent: item.isCurrent
- )
- cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
- return cell
- }
-
- func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- let container = UIView()
- let label = UILabel()
- label.translatesAutoresizingMaskIntoConstraints = false
- label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
- label.textColor = UIColor(white: 0.96, alpha: 1)
- label.text = searchSections[section].title
- label.numberOfLines = 2
- container.addSubview(label)
-
- NSLayoutConstraint.activate([
- label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
- label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
- label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
- label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
- ])
- return container
- }
-
- func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
- 40
- }
-
- func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
- UITableView.automaticDimension
- }
-
- func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- 116
- }
-
- func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- tableView.deselectRow(at: indexPath, animated: true)
- onSelectMatch?(item(at: indexPath).matchIndex)
- }
-}
-
-extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
-
- func textFieldShouldClear(_ textField: UITextField) -> Bool {
- DispatchQueue.main.async { [weak self] in
- self?.onSearchTextChanged?("")
- }
- return true
- }
-}
-
-private final class RDEPUBReaderSearchResultCell: UITableViewCell {
-
- static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
-
- static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
-
- static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
-
- static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
-
- static var highlightTextColor = UIColor.systemBlue
-
- static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
-
- private let cardView = UIView()
-
- private let previewLabel = UILabel()
-
- override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
- super.init(style: style, reuseIdentifier: reuseIdentifier)
- setupSubviews()
- setupConstraints()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func prepareForReuse() {
- super.prepareForReuse()
- previewLabel.attributedText = nil
- }
-
- func configure(previewText: String, keyword: String, isCurrent: Bool) {
- selectionStyle = .none
- backgroundColor = .clear
- contentView.backgroundColor = .clear
- cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
- previewLabel.attributedText = attributedPreviewText(
- previewText,
- keyword: keyword,
- isCurrent: isCurrent
- )
- }
-
- private func setupSubviews() {
- cardView.translatesAutoresizingMaskIntoConstraints = false
- previewLabel.translatesAutoresizingMaskIntoConstraints = false
-
- contentView.addSubview(cardView)
- cardView.addSubview(previewLabel)
-
- cardView.layer.cornerRadius = 16
- cardView.clipsToBounds = true
-
- previewLabel.numberOfLines = 0
- previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
- }
-
- private func setupConstraints() {
- NSLayoutConstraint.activate([
- cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
- cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
- cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
- cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
-
- previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
- previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
- previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
- previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
- ])
- }
-
- private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
- let normalizedText = previewText
- .replacingOccurrences(of: "\n", with: " ")
- .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- .trimmingCharacters(in: .whitespacesAndNewlines)
-
- let paragraphStyle = NSMutableParagraphStyle()
- paragraphStyle.lineSpacing = 8
-
- let attributed = NSMutableAttributedString(
- string: normalizedText,
- attributes: [
- .font: UIFont.systemFont(ofSize: 18, weight: .regular),
- .foregroundColor: Self.primaryTextColor,
- .paragraphStyle: paragraphStyle
- ]
- )
-
- let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !searchKeyword.isEmpty else { return attributed }
-
- let nsText = normalizedText as NSString
- var searchRange = NSRange(location: 0, length: nsText.length)
- let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
-
- while searchRange.length > 0 {
- let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else { break }
- attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- guard nextLocation < nsText.length else { break }
- searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
- }
-
- return attributed
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift
deleted file mode 100644
index 7f22a2d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import Foundation
-
-public struct RDEPUBReaderTableOfContentsItem: Equatable {
-
- public var title: String
-
- public var href: String
-
- public var depth: Int
-
- public var pageNumber: Int?
-
- public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
- self.title = title
- self.href = href
- self.depth = depth
- self.pageNumber = pageNumber
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift
deleted file mode 100644
index 9189cbb..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-import UIKit
-
-open class RDEPUBReaderToolView: UIView {
-
- private let lineView = UIView()
-
- private let lineHeight: CGFloat = 0.5
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- translatesAutoresizingMaskIntoConstraints = false
- addSubview(lineView)
- }
-
- public required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- open override func layoutSubviews() {
- super.layoutSubviews()
- lineView.frame = lineFrame(in: bounds)
- }
-
- open func apply(theme: RDEPUBReaderTheme) {
- backgroundColor = .white
- lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
- }
-
- open func lineFrame(in bounds: CGRect) -> CGRect {
- CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
- }
-}
-
-final class RDEPUBReaderTintButton: UIButton {
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift
deleted file mode 100644
index 25ba54a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-import UIKit
-
-/// 顶部工具栏协议:宿主可提供自定义顶栏,只需实现以下回调与状态方法。
-/// 内置 `RDEPUBReaderTopToolView` 已实现本协议。
-/// 通过 `RDEPUBReaderDependencies.makeTopToolView` 工厂注入自定义实现。
-public protocol RDEPUBReaderTopToolViewProtocol: AnyObject {
-
- /// 返回 / 关闭
- var onBack: (() -> Void)? { get set }
-
- /// 搜索
- var onSearch: (() -> Void)? { get set }
-
- /// 切换书签
- var onToggleBookmark: (() -> Void)? { get set }
-
- /// 应用主题
- func apply(theme: RDEPUBReaderTheme)
-
- /// 设置标题
- func setTitle(_ title: String?)
-
- /// 书签按钮是否可用
- func setBookmarkEnabled(_ isEnabled: Bool)
-
- /// 书签按钮选中态(当前页是否已加书签)
- func setBookmarkSelected(_ isSelected: Bool)
-}
-
-/// 底部工具栏协议:宿主可提供自定义底栏。内置 `RDEPUBReaderBottomToolView` 已实现本协议。
-/// 通过 `RDEPUBReaderDependencies.makeBottomToolView` 工厂注入自定义实现。
-public protocol RDEPUBReaderBottomToolViewProtocol: AnyObject {
-
- /// 目录
- var onShowTableOfContents: (() -> Void)? { get set }
-
- /// 书签列表
- var onShowBookmarks: (() -> Void)? { get set }
-
- /// 高亮列表
- var onShowHighlights: (() -> Void)? { get set }
-
- /// 新增高亮 / 标注
- var onAddHighlight: (() -> Void)? { get set }
-
- /// 设置面板
- var onShowSettings: (() -> Void)? { get set }
-
- /// 应用主题
- func apply(theme: RDEPUBReaderTheme)
-
- /// 按配置更新各入口可见性
- func updateVisibility(showsTableOfContents: Bool, allowsHighlights: Bool, showsSettingsPanel: Bool)
-
- /// 书签列表按钮是否可用
- func setBookmarksEnabled(_ isEnabled: Bool)
-
- /// 新增高亮按钮是否可用(有选区时)
- func setAddHighlightEnabled(_ isEnabled: Bool)
-
- /// 高亮列表按钮是否可用
- func setHighlightsEnabled(_ isEnabled: Bool)
-}
-
-/// 顶部工具栏具体类型:UIView 且实现顶栏协议
-public typealias RDEPUBReaderTopToolViewProviding = UIView & RDEPUBReaderTopToolViewProtocol
-
-/// 底部工具栏具体类型:UIView 且实现底栏协议
-public typealias RDEPUBReaderBottomToolViewProviding = UIView & RDEPUBReaderBottomToolViewProtocol
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift
deleted file mode 100644
index 28ddb84..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift
+++ /dev/null
@@ -1,136 +0,0 @@
-import UIKit
-
-public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView, RDEPUBReaderTopToolViewProtocol {
-
- public var onBack: (() -> Void)?
-
- public var onToggleBookmark: (() -> Void)?
-
- public var onSearch: (() -> Void)?
-
- private let backButton = RDEPUBReaderTintButton(type: .system)
- private let searchButton = RDEPUBReaderTintButton(type: .system)
- private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
- private let titleLabel: UILabel = {
- let label = UILabel()
- label.textAlignment = .center
- label.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
- label.numberOfLines = 1
- return label
- }()
- private var isBookmarked = false
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.topToolbar"
- self.backgroundColor = .white
- addSubview(backButton)
- addSubview(searchButton)
- addSubview(bookmarkButton)
- addSubview(titleLabel)
-
- backButton.translatesAutoresizingMaskIntoConstraints = false
- searchButton.translatesAutoresizingMaskIntoConstraints = false
- bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
-
- backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
- searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
- bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
-
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
- backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
- backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
- backButton.widthAnchor.constraint(equalToConstant: 44),
- backButton.heightAnchor.constraint(equalToConstant: 44),
-
- bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
- bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
- bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
- bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
- bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
-
- searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
- searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
- searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
- searchButton.widthAnchor.constraint(equalToConstant: 44),
- searchButton.heightAnchor.constraint(equalToConstant: 44),
-
- titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
- titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
- titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
- ])
-
- if #available(iOS 13.0, *) {
- backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
- searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
- } else {
- backButton.setTitle("返回", for: .normal)
- searchButton.setTitle("搜索", for: .normal)
- }
- backButton.accessibilityIdentifier = "epub.reader.back"
- searchButton.accessibilityIdentifier = "epub.reader.search"
- bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
- titleLabel.accessibilityIdentifier = "epub.reader.title"
- updateBookmarkButtonAppearance()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override public func lineFrame(in bounds: CGRect) -> CGRect {
- CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
- }
-
- override public func apply(theme: RDEPUBReaderTheme) {
- super.apply(theme: theme)
- titleLabel.textColor = theme.toolControlTextColor
- backButton.tintColor = theme.toolControlTextColor
- searchButton.tintColor = theme.toolControlTextColor
- bookmarkButton.tintColor = theme.toolControlTextColor
- if #unavailable(iOS 13.0) {
- backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
- searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
- bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
- }
- updateBookmarkButtonAppearance()
- }
-
- public func setTitle(_ title: String?) {
- titleLabel.text = title
- }
-
- public func setBookmarkSelected(_ isSelected: Bool) {
- isBookmarked = isSelected
- updateBookmarkButtonAppearance()
- }
-
- public func setBookmarkEnabled(_ isEnabled: Bool) {
- bookmarkButton.isEnabled = isEnabled
- bookmarkButton.alpha = isEnabled ? 1 : 0.45
- }
-
- @objc private func backAction() {
- onBack?()
- }
-
- @objc private func searchAction() {
- onSearch?()
- }
-
- @objc private func bookmarkAction() {
- onToggleBookmark?()
- }
-
- private func updateBookmarkButtonAppearance() {
- if #available(iOS 13.0, *) {
- let imageName = isBookmarked ? "bookmark.fill" : "bookmark"
- bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
- } else {
- bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
- }
- bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift
deleted file mode 100644
index 8d72dff..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-import UIKit
-
-/// Debug-only automation (`--demo-settings-flip`) that replays the user
-/// gesture sequence suspected of producing stale page tables: open the
-/// settings panel, change the line height, close the panel, flip pages —
-/// with several timing variants aimed at the preview-repagination and
-/// in-flight chapter build races. Combine with
-/// `--demo-pagination-validate` to detect any resulting metric mismatch.
-enum RDEPUBSettingsFlipAutomation {
-
- static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-settings-flip")
-
- private(set) static var hasStarted = false
-
- static func startIfNeeded(controller: RDEPUBReaderController) {
- guard isEnabled, !hasStarted else { return }
- hasStarted = true
- print("[SETTINGS-FLIP] scheduled")
-
- // Pass 1 starts while progressive pagination of the freshly opened
- // book is still running, so prefetch builds are in flight.
- run(after: 2.0) { [weak controller] in
- guard let controller else { return }
- pass(controller: controller, index: 1, lineHeight: 1.8, changeToCloseDelay: 1.2) {
- pass(controller: controller, index: 2, lineHeight: 1.6, changeToCloseDelay: 0.05) {
- pass(controller: controller, index: 3, lineHeight: 1.8, changeToCloseDelay: 0.3) {
- print("[SETTINGS-FLIP] finished all passes")
- }
- }
- }
- }
- }
-
- /// One panel round-trip: present → change line height → close after
- /// `changeToCloseDelay` → flip pages forward and back.
- private static func pass(
- controller: RDEPUBReaderController,
- index: Int,
- lineHeight: CGFloat,
- changeToCloseDelay: TimeInterval,
- completion: @escaping () -> Void
- ) {
- print("[SETTINGS-FLIP] pass \(index) present panel")
- controller.presentSettings()
-
- run(after: 0.7) { [weak controller] in
- guard let controller else { return }
- print("[SETTINGS-FLIP] pass \(index) set lineHeightMultiple=\(lineHeight)")
- controller.updateConfiguration { $0.lineHeightMultiple = lineHeight }
-
- run(after: changeToCloseDelay) { [weak controller] in
- guard let controller else { return }
- print("[SETTINGS-FLIP] pass \(index) close panel")
- controller.dismiss(animated: true) { [weak controller] in
- controller?.runtime.settingsPanelDidDisappear()
- run(after: 1.0) { [weak controller] in
- guard let controller else { return }
- flipPages(controller: controller, index: index, completion: completion)
- }
- }
- }
- }
- }
-
- private static func flipPages(
- controller: RDEPUBReaderController,
- index: Int,
- completion: @escaping () -> Void
- ) {
- let startPage = controller.readerView.currentPage + 1
- let offsets = [1, 2, 3, 2, 1, 0]
- print("[SETTINGS-FLIP] pass \(index) flip pages from \(startPage)")
- for (step, offset) in offsets.enumerated() {
- run(after: 0.6 * Double(step + 1)) { [weak controller] in
- _ = controller?.go(toPageNumber: startPage + offset, animated: false)
- }
- }
- run(after: 0.6 * Double(offsets.count + 1) + 0.5, block: completion)
- }
-
- private static func run(after delay: TimeInterval, block: @escaping () -> Void) {
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift
deleted file mode 100644
index 2778689..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import UIKit
-
-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
- }
-}
-
-enum RDEPUBViewportChangeReason {
-
- case viewLayout
-
- case orientationTransition
-}
-
-typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift
deleted file mode 100644
index 241c514..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-import UIKit
-
-protocol RDEPUBWebContentViewDelegate: AnyObject {
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?)
-
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
-}
-
-final class RDEPUBWebContentView: UIView {
- weak var delegate: RDEPUBWebContentViewDelegate?
-
- /// The current href of the loaded chapter (exposed for image resolution).
- var currentHref: String {
- epubWebView.currentHref
- }
-
- private let epubWebView = RDEPUBWebView()
- private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
- private let pageNumberLabel: UILabel = {
- let label = UILabel()
- label.font = UIFont.systemFont(ofSize: 13)
- return label
- }()
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- clipsToBounds = true
- layer.masksToBounds = true
-
- addSubview(epubWebView)
- addSubview(decorationOverlayView)
- addSubview(pageNumberLabel)
- epubWebView.delegate = self
- epubWebView.onDecorationsResolved = { [weak self] decorations in
- self?.decorationOverlayView.applyDecorations(decorations)
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func layoutSubviews() {
- super.layoutSubviews()
- epubWebView.frame = bounds
- decorationOverlayView.frame = bounds
-
- let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
- pageNumberLabel.frame = CGRect(
- x: bounds.width - labelSize.width - 24,
- y: bounds.height - labelSize.height - 20,
- width: labelSize.width,
- height: labelSize.height
- )
- }
-
- func configure(
- publication: RDEPUBPublication,
- request: RDEPUBRenderRequest,
- pageNumber: Int,
- totalPages: Int,
- theme: RDEPUBReaderTheme
- ) {
- backgroundColor = theme.contentBackgroundColor
- pageNumberLabel.textColor = theme.contentTextColor
- pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
- decorationOverlayView.applyDecorations([])
- epubWebView.load(publication: publication, request: request)
- setNeedsLayout()
- }
-
- func releaseResources() {
- decorationOverlayView.applyDecorations([])
- epubWebView.reset()
- epubWebView.delegate = self
- epubWebView.onDecorationsResolved = { [weak self] decorations in
- self?.decorationOverlayView.applyDecorations(decorations)
- }
- }
-}
-
-extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
- func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
- delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
- }
-
- func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
- delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
- }
-
- func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
- delegate?.epubWebContentView(self, didRequestSelectionAction: action)
- }
-
- func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
- delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
- }
-
- func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
- delegate?.epubWebContentView(self, didActivateExternalLink: url)
- }
-
- func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
- delegate?.epubWebContentView(self, didLogJavaScriptError: message)
- }
- func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
- func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {
- delegate?.epubWebContentView(self, didTapImageWithSource: src, sourceRect: sourceRect)
- }
- func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
- delegate?.epubWebContentView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect)
- }
-}
-
-extension RDEPUBWebContentViewDelegate {
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
- // Default: no-op. Implementors can present an image viewer.
- }
- func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
- // Default: no-op. Implementors can present a footnote tooltip.
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift
deleted file mode 100644
index 2448e3d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import UIKit
-
-final class RDEPUBWebDecorationOverlayView: UIView {
- private var decorations: [RDEPUBTextOverlayDecoration] = []
- private let verticalAdjustment: CGFloat = -1
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- isOpaque = false
- isUserInteractionEnabled = false
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
- self.decorations = decorations.filter { !$0.rects.isEmpty }
- setNeedsDisplay()
- }
-
- override func draw(_ rect: CGRect) {
- guard let context = UIGraphicsGetCurrentContext() else { return }
-
- for decoration in decorations {
- switch decoration.kind {
- case .underline:
- context.setStrokeColor(decoration.color.cgColor)
- context.setLineWidth(2)
- for underlineRect in decoration.rects {
- let y = underlineRect.maxY - 1
- context.move(to: CGPoint(x: underlineRect.minX, y: y))
- context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
- context.strokePath()
- }
- default:
- context.setFillColor(decoration.color.cgColor)
- for selectionRect in decoration.rects {
- let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment)
- let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
- context.addPath(path.cgPath)
- context.fillPath()
- }
- }
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift
deleted file mode 100644
index d18bf09..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift
+++ /dev/null
@@ -1,516 +0,0 @@
-
-import UIKit
-
-public final class RDEpubURLReaderController: UIViewController {
- private struct PendingDemoPageRequest {
- let pageNumber: Int
- let animated: Bool
- var attemptCount: Int
- }
-
- private let bookURL: URL
-
- private let epubConfiguration: RDEPUBReaderConfiguration
-
- private var embeddedController: UIViewController?
- private let demoStateLabel: UILabel = {
- let label = UILabel()
- label.accessibilityIdentifier = "demo.reader.state"
- label.font = .systemFont(ofSize: 1)
- label.textColor = .clear
- label.alpha = 0.01
- label.isAccessibilityElement = true
- label.text = "reader=opening"
- return label
- }()
- private var pendingDemoPageRequest: PendingDemoPageRequest?
- private var isRetryingPendingDemoPage = false
- private let maxPendingDemoPageAttempts = 24
- private let pendingDemoPageRetryDelay: TimeInterval = 0.25
- private var demoStateTimer: Timer?
- private var lastEmittedDemoState = ""
- private var pendingSearchKeyword: String?
- private var externalLinkActivationCount = 0
- private var lastActivatedExternalURL: URL?
- private var lastReaderErrorDescription = "none"
-
- public init(
- bookURL: URL,
- epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
- ) {
- self.bookURL = bookURL
- self.epubConfiguration = epubConfiguration
- super.init(nibName: nil, bundle: nil)
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- public override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .systemBackground
- title = bookURL.deletingPathExtension().lastPathComponent
- RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
- embedReaderController()
- installDemoStateLabel()
- }
-
- public override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- startDemoStateTimerIfNeeded()
- refreshDemoState()
- }
-
- public override func viewDidDisappear(_ animated: Bool) {
- super.viewDidDisappear(animated)
- stopDemoStateTimer()
- }
-
- deinit {
- stopDemoStateTimer()
- }
-
- public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
- readerController?.configuration.displayType = displayType
- emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
- }
-
- @discardableResult
- public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
- let moved = performDemoPageNavigation(pageNumber, animated: animated)
- if moved {
- pendingDemoPageRequest = nil
- isRetryingPendingDemoPage = false
- } else {
- queuePendingDemoPageNavigation(pageNumber, animated: animated)
- }
- return moved
- }
-
- public func runDemoDisplaySequence(
- _ displayTypes: [RDEpubReaderView.DisplayType],
- initialPageNumber: Int? = nil,
- stepDelay: TimeInterval = 1.0
- ) {
- let normalizedDelay = max(stepDelay, 0.1)
- if let initialPageNumber {
- DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in
- _ = self?.goToDemoPage(initialPageNumber)
- }
- }
-
- for (index, displayType) in displayTypes.enumerated() {
- let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1))
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
- self?.applyDemoDisplayType(displayType)
- }
- }
- }
-
- public func performDemoSearch(keyword: String) {
- guard let readerController else { return }
- readerController.showSearchBar()
- if readerController.parser != nil || readerController.textBook != nil {
- submitSearchAfterDelay(keyword: keyword, retries: 10)
- } else {
- pendingSearchKeyword = keyword
- }
- }
-
- private func submitSearchAfterDelay(keyword: String, retries: Int = 0) {
- guard let readerController else { return }
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
- guard let self else { return }
- readerController.search(keyword: keyword)
- readerController.updateSearchCount()
- // 按需加载的书打开初期搜索引擎可能尚未就绪(bookPageMap 未建立),
- // 只要还没有命中就继续重试,交由搜索自身的 token 去重。
- if retries > 0,
- readerController.searchState?.matches.isEmpty != false {
- self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
- }
- }
- }
-
- private func executePendingSearchIfNeeded() {
- guard let keyword = pendingSearchKeyword else { return }
- guard let readerController, readerController.parser != nil || readerController.textBook != nil else {
- return
- }
- pendingSearchKeyword = nil
- submitSearchAfterDelay(keyword: keyword, retries: 10)
- }
-
- private func embedReaderController() {
- let controller: UIViewController
- if bookURL.pathExtension.lowercased() == "epub" {
- controller = RDEPUBReaderController(
- epubURL: bookURL,
- configuration: epubConfiguration
- )
- } else {
- let bookIdentifier = bookURL.lastPathComponent
- let bookTitle = bookURL.deletingPathExtension().lastPathComponent
- let pageSize = currentTextPageSize()
- let renderStyle = currentTextRenderStyle()
- let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
- let edgeInsets = UIEdgeInsets(
- top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
- left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
- bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom),
- right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right)
- )
- let builder = RDEpubPlainTextBookBuilder(
- layoutConfig: RDEPUBTextLayoutConfig(
- frameWidth: pageSize.width,
- frameHeight: pageSize.height,
- edgeInsets: edgeInsets,
- numberOfColumns: 1,
- columnGap: 20,
- avoidOrphans: false,
- avoidWidows: false,
- avoidPageBreakInsideEnabled: true,
- hyphenation: true,
- imageMaxHeightRatio: 0.85
- )
- )
- if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
- controller = RDEPUBReaderController(
- textBook: textBook,
- bookIdentifier: bookIdentifier,
- title: bookTitle,
- textFileURL: bookURL,
- configuration: epubConfiguration
- )
- } else {
-
- let fallback = UIViewController()
- let textView = UITextView()
- textView.isEditable = false
- textView.text = rd_decodeTextFile(url: bookURL)
- fallback.view = textView
- controller = fallback
- }
- }
- embeddedController = controller
- addChild(controller)
- controller.view.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(controller.view)
- NSLayoutConstraint.activate([
- controller.view.topAnchor.constraint(equalTo: view.topAnchor),
- controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ])
- controller.didMove(toParent: self)
- readerController?.delegate = self
- emitDemoState()
- }
-
- private var readerController: RDEPUBReaderController? {
- embeddedController as? RDEPUBReaderController
- }
-
- @discardableResult
- private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
- guard let readerController else { return false }
- guard pageNumber > 0 else { return false }
-
- let knownPages = readerController.readerContext.bookPageMap?.totalPages
- ?? readerController.textBook?.pages.count
- ?? readerController.activePages.count
- let numPages = readerController.readerView.numberOfPages()
- let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
- if moved {
- emitDemoState(prefix: "page=\(pageNumber)")
- }
- return moved
- }
-
- private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
- if let pendingDemoPageRequest,
- pendingDemoPageRequest.pageNumber == pageNumber,
- pendingDemoPageRequest.animated == animated {
- return
- }
-
- pendingDemoPageRequest = PendingDemoPageRequest(
- pageNumber: pageNumber,
- animated: animated,
- attemptCount: 0
- )
- retryPendingDemoPageIfNeeded()
- }
-
- private func retryPendingDemoPageIfNeeded() {
- guard !isRetryingPendingDemoPage else { return }
- guard pendingDemoPageRequest != nil else { return }
-
- isRetryingPendingDemoPage = true
- DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
- self?.attemptPendingDemoPageNavigation()
- }
- }
-
- private func attemptPendingDemoPageNavigation() {
- guard var pendingDemoPageRequest else {
- isRetryingPendingDemoPage = false
- return
- }
-
- if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
- self.pendingDemoPageRequest = nil
- isRetryingPendingDemoPage = false
- return
- }
-
- pendingDemoPageRequest.attemptCount += 1
- if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
- self.pendingDemoPageRequest = nil
- isRetryingPendingDemoPage = false
- return
- }
-
- self.pendingDemoPageRequest = pendingDemoPageRequest
- DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
- self?.attemptPendingDemoPageNavigation()
- }
- }
-
- private func logDemoState(prefix: String) {
- guard let readerController else { return }
- let location = readerController.currentLocation
- let href = location?.href ?? "nil"
- let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
- let page = readerController.currentPageNumber.map(String.init) ?? "nil"
- }
-
- private func installDemoStateLabel() {
- view.addSubview(demoStateLabel)
- demoStateLabel.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor),
- demoStateLabel.widthAnchor.constraint(equalToConstant: 1),
- demoStateLabel.heightAnchor.constraint(equalToConstant: 1)
- ])
- }
-
- private func startDemoStateTimerIfNeeded() {
- guard demoStateTimer == nil else { return }
- demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
- self?.refreshDemoState(logIfChanged: false)
- }
- if let demoStateTimer {
- RunLoop.main.add(demoStateTimer, forMode: .common)
- }
- }
-
- private func stopDemoStateTimer() {
- demoStateTimer?.invalidate()
- demoStateTimer = nil
- }
-
- private func emitDemoState(prefix: String? = nil) {
- refreshDemoState(logPrefix: prefix, logIfChanged: true)
- }
-
- private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
- let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
- let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
- let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
- let highlights = readerController?.highlights.count ?? 0
- let bookmarks = readerController?.activeBookmarks.count ?? 0
- let selection = readerController?.currentSelection == nil ? 0 : 1
- let location = readerController?.currentLocation
- let href = encodedDemoLocationHref(location?.href)
- let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
- let cfi = encodedDemoField(location?.cfi)
- let lastCFI = encodedDemoField(location?.lastCFI)
- let rangeCFI = encodedDemoField(location?.rangeCFI)
- let mapSnapshot = demoPaginationSnapshot()
- let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
- let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
- let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
- ?? (fileCount: 0, totalBytes: 0)
- let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
- let state = [
- "reader=opened",
- "page=\(page)",
- "display=\(display)",
- "toolbar=\(toolbar)",
- "highlights=\(highlights)",
- "bookmarks=\(bookmarks)",
- "selection=\(selection)",
- "href=\(href)",
- "progression=\(progression)",
- "cfi=\(cfi)",
- "lastCFI=\(lastCFI)",
- "rangeCFI=\(rangeCFI)",
- "mode=\(mapSnapshot.mode)",
- "pagination=\(mapSnapshot.phase)",
- "knownPages=\(mapSnapshot.knownPages)",
- "knownChapters=\(mapSnapshot.knownChapters)",
- "buildableChapters=\(mapSnapshot.buildableChapters)",
- "avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
- "avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
- "windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
- "parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
- "parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
- "inspectable=\(inspectable ? 1 : 0)",
- "streamedResources=\(resourceMetrics.streamedResponses)",
- "inMemoryResources=\(resourceMetrics.inMemoryResponses)",
- "resourceFailures=\(resourceMetrics.failures)",
- "cacheFiles=\(cacheStats.fileCount)",
- "cacheBytes=\(cacheStats.totalBytes)",
- "externalLinks=\(externalLinkActivationCount)",
- "lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
- "lastError=\(encodedDemoField(lastReaderErrorDescription))",
- "searchMatchText=\(encodedDemoField(currentSearchMatchText()))",
- "footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))"
- ].joined(separator: " ")
- demoStateLabel.text = state
- if let logPrefix {
- logDemoState(prefix: logPrefix)
- } else if logIfChanged, state != lastEmittedDemoState {
- }
- lastEmittedDemoState = state
- }
-
- private func encodedDemoLocationHref(_ href: String?) -> String {
- guard let href, !href.isEmpty else { return "nil" }
- return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
- }
-
- private func encodedDemoField(_ value: String?) -> String {
- guard let value, !value.isEmpty else { return "nil" }
- return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
- }
-
- private func currentSearchMatchText() -> String {
- guard let match = readerController?.searchState?.currentMatch else {
- return "none"
- }
- // Primary path: extract the exact matched text from the chapter content
- if let rangeLocation = match.rangeLocation,
- let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) {
- let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
- if nsRange.location >= 0,
- nsRange.location + nsRange.length <= chapterData.attributedContent.length {
- return chapterData.attributedContent.attributedSubstring(from: nsRange).string
- }
- }
- // Fallback: chapter data may not be cached yet (on-demand loading).
- // If the match length equals the keyword length, the keyword itself
- // is the match text. Otherwise extract from previewText at the known offset.
- if let keyword = readerController?.searchState?.keyword,
- match.rangeLength == keyword.count {
- return keyword
- }
- // Final fallback: return "none" to indicate data not yet available
- return "none"
- }
-
- private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
- guard let readerController else {
- return ("unavailable", "none", 0, 0, 0)
- }
-
- if let bookPageMap = readerController.readerContext.bookPageMap {
- let buildableChapters = readerController.publication?.spine.filter {
- $0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
- }.count ?? 0
- let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
- return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
- }
-
- if let textBook = readerController.textBook {
- return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
- }
-
- let snapshotChapters = readerController.activeChapters.count
- let snapshotPages = readerController.activePages.count
- return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
- }
-
- private func currentTextPageSize() -> CGSize {
- UIScreen.main.bounds.size
- }
-
- private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
- let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
- let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
- return RDEPUBTextRenderStyle(
- font: font,
- lineSpacing: lineSpacing,
- textColor: epubConfiguration.theme.contentTextColor,
- backgroundColor: epubConfiguration.theme.contentBackgroundColor
- )
- }
-
- private func rd_decodeTextFile(url: URL) -> String {
- if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
- return content
- }
- if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
- return content
- }
- if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
- return content
- }
- return ""
- }
-}
-
-extension RDEpubURLReaderController: RDEPUBReaderDelegate {
- public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
- retryPendingDemoPageIfNeeded()
- executePendingSearchIfNeeded()
- emitDemoState()
- }
-
- public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
- retryPendingDemoPageIfNeeded()
- executePendingSearchIfNeeded()
- emitDemoState()
- }
-
- public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {
- emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)")
- }
-
- public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {
- emitDemoState(prefix: "highlights=\(highlights.count)")
- }
-
- public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
- emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
- }
-
- public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
- externalLinkActivationCount += 1
- lastActivatedExternalURL = url
- emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
- }
-
- public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
- lastReaderErrorDescription = String(describing: error)
- emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
- }
-}
-
-private extension RDEpubReaderView.DisplayType {
-
- var demoArgumentValue: String {
- switch self {
- case .pageCurl:
- return "pageCurl"
- case .horizontalScroll:
- return "horizontalScroll"
- case .verticalScroll:
- return "verticalScroll"
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift
deleted file mode 100644
index 6d3dfa2..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-import Foundation
-
-enum RDEPUBBackgroundTrace {
-
- static func log(_ scope: String, _ message: String) {
- let threadRole = Thread.isMainThread ? "main" : "bg"
- let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
- print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift
deleted file mode 100644
index 79010ea..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift
+++ /dev/null
@@ -1,112 +0,0 @@
-import Foundation
-
-struct RDEPUBBookPageMapEntry {
-
- let spineIndex: Int
-
- let href: String
-
- let title: String
-
- let pageCount: Int
-
- let absolutePageStart: Int
-
- let fragmentOffsets: [String: Int]
-}
-
-struct RDEPUBBookPageMap {
-
- let entries: [RDEPUBBookPageMapEntry]
-
- private let indexBySpine: [Int: Int]
-
- let totalPages: Int
-
- init(entries: [RDEPUBBookPageMapEntry]) {
- self.entries = entries
- var mapping: [Int: Int] = [:]
- for (i, entry) in entries.enumerated() {
- mapping[entry.spineIndex] = i
- }
- self.indexBySpine = mapping
- self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
- }
-
- static let empty = RDEPUBBookPageMap(entries: [])
-
- func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
- guard let idx = indexBySpine[spineIndex] else { return nil }
- let entry = entries[idx]
- guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
- return entry.absolutePageStart + localPageIndex
- }
-
- func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
- guard absolutePage >= 0, absolutePage < totalPages else { return nil }
-
- var lo = 0, hi = entries.count
- while lo < hi {
- let mid = lo + (hi - lo) / 2
- if entries[mid].absolutePageStart <= absolutePage {
- lo = mid + 1
- } else {
- hi = mid
- }
- }
- guard lo > 0 else { return nil }
- return entries[lo - 1].spineIndex
- }
-
- func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
- guard let si = spineIndex(forAbsolutePage: absolutePage),
- let idx = indexBySpine[si] else { return nil }
- let entry = entries[idx]
- let local = absolutePage - entry.absolutePageStart
- guard local >= 0, local < entry.pageCount else { return nil }
- return local
- }
-
- func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
- guard let idx = indexBySpine[spineIndex] else { return nil }
- return entries[idx]
- }
-
- func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
- indexBySpine[spineIndex]
- }
-
- func pageCount(forSpineIndex spineIndex: Int) -> Int? {
- entry(forSpineIndex: spineIndex)?.pageCount
- }
-
- var totalChapters: Int { entries.count }
-
- struct Builder {
-
- private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
-
- mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
- items.append((spineIndex, href, title, pageCount, fragmentOffsets))
- }
-
- func build() -> RDEPUBBookPageMap {
-
- let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
- var entries: [RDEPUBBookPageMapEntry] = []
- var absolutePageStart = 0
- for item in sorted {
- entries.append(RDEPUBBookPageMapEntry(
- spineIndex: item.spineIndex,
- href: item.href,
- title: item.title,
- pageCount: item.pageCount,
- absolutePageStart: absolutePageStart,
- fragmentOffsets: item.fragmentOffsets
- ))
- absolutePageStart += item.pageCount
- }
- return RDEPUBBookPageMap(entries: entries)
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift
deleted file mode 100644
index 3892060..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-import Foundation
-
-struct RDEPUBChapterCacheKey: Hashable {
-
- let bookID: String
-
- let spineIndex: Int
-
- let renderSignature: String
-
- let chapterContentHash: String
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift
deleted file mode 100644
index 7f7fef3..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-import Foundation
-
-final class RDEPUBChapterDataCache {
-
- private var storage: [Int: RDEPUBRuntimeChapter] = [:]
- private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
- private let maxEntryCount: Int
-
- private let lock = NSLock()
-
- init(maxEntryCount: Int = 30) {
- self.maxEntryCount = maxEntryCount
- }
-
- subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
- get {
- lock.lock()
- defer { lock.unlock() }
- guard let chapter = storage[spineIndex] else {
- return nil
- }
- touchLocked(spineIndex)
- return chapter
- }
- set {
- lock.lock()
- defer { lock.unlock() }
- if let newValue {
- storage[spineIndex] = newValue
- touchLocked(spineIndex)
- // Evict oldest entries if over limit
- evictIfNeededLocked()
- } else {
- storage.removeValue(forKey: spineIndex)
- accessOrder.removeAll { $0 == spineIndex }
- }
- }
- }
-
- var storedSpineIndices: [Int] {
- lock.lock()
- defer { lock.unlock() }
- return Array(storage.keys)
- }
-
- func remove(spineIndex: Int) {
- lock.lock()
- defer { lock.unlock() }
- storage.removeValue(forKey: spineIndex)
- accessOrder.removeAll { $0 == spineIndex }
- }
-
- func removeAll() {
- lock.lock()
- defer { lock.unlock() }
- storage.removeAll()
- accessOrder.removeAll()
- }
-
- /// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
- /// Must be called while holding lock.
- private func evictIfNeededLocked() {
- while storage.count > maxEntryCount, let oldest = accessOrder.first {
- storage.removeValue(forKey: oldest)
- accessOrder.removeFirst()
- }
- }
-
- /// Marks an entry as most recently used.
- /// Must be called while holding lock.
- private func touchLocked(_ spineIndex: Int) {
- accessOrder.removeAll { $0 == spineIndex }
- accessOrder.append(spineIndex)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift
deleted file mode 100644
index a4428c7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift
+++ /dev/null
@@ -1,838 +0,0 @@
-import Foundation
-
-final class RDEPUBChapterLoader {
-
- private typealias LoadCompletion = (Result) -> Void
-
- private struct PendingLoad {
- var priority: LoadPriority
- var completions: [LoadCompletion]
- }
-
- private weak var context: RDEPUBReaderContext?
-
- private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
-
- private let pendingLoadsLock = NSLock()
-
- private var pendingLoads: [Int: PendingLoad] = [:]
-
- var onDeferredCFIMapReady: ((Int) -> Void)?
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
- summaryDiskCache = cache
- }
-
- enum LoadPriority {
-
- case navigation
-
- case preview
-
- case prefetch
- }
-
- private enum LoadRegistrationResult {
- case created
- case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
- }
-
- func loadChapter(
- spineIndex: Int,
- store: RDEPUBChapterRuntimeStore,
- priority: LoadPriority = .navigation,
- completion: @escaping (Result) -> Void
- ) {
- let layoutSnapshot = context?.makeLayoutSnapshot()
- loadChapterWithSnapshot(
- spineIndex: spineIndex,
- store: store,
- priority: priority,
- layoutSnapshot: layoutSnapshot,
- completion: completion
- )
- }
-
- private func loadChapterWithSnapshot(
- spineIndex: Int,
- store: RDEPUBChapterRuntimeStore,
- priority: LoadPriority,
- layoutSnapshot: RDEPUBLayoutSnapshot?,
- completion: @escaping (Result) -> Void
- ) {
- if let cached = store.chapterData(for: spineIndex) {
- RDEPUBBackgroundTrace.log(
- "ChapterLoad",
- "memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
- )
- if let context {
- scheduleDeferredCFIMapBuildIfNeeded(
- for: cached,
- cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
- store: store
- )
- }
- DispatchQueue.main.async {
- completion(.success(cached))
- }
- return
- }
-
- let completionOnMain: LoadCompletion = { result in
- DispatchQueue.main.async {
- completion(result)
- }
- }
-
- let registration = registerPendingLoad(
- spineIndex: spineIndex,
- priority: priority,
- completion: completionOnMain
- )
-
- switch registration {
- case .joined(let existingPriority, let effectivePriority):
- RDEPUBBackgroundTrace.log(
- "ChapterLoad",
- "join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
- )
- return
- case .created:
- break
- }
-
- store.markBuilding(true)
- _ = store.beginPendingChapterLoad(for: spineIndex)
-
- store.chapterLoadQueue.async { [weak self] in
- guard let self, let context = self.context else {
- store.endPendingChapterLoad(for: spineIndex)
- store.markBuilding(false)
- self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
- return
- }
- let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
- let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
-
- let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
- let diskSummary: RDEPUBChapterSummary?
- if precomputedPageRanges == nil {
- diskSummary = self.summaryDiskCache?.read(for: cacheKey)
- } else {
- diskSummary = nil
- }
- let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
- let availablePageRanges = precomputedPageRanges ?? diskPageRanges
-
- let pageRangeSource: String
- if precomputedPageRanges != nil {
- pageRangeSource = "HIT(memoryPageCount)"
- } else if diskPageRanges != nil {
- pageRangeSource = "HIT(diskSummary)"
- } else {
- pageRangeSource = "MISS(fullRender)"
- }
- RDEPUBBackgroundTrace.log(
- "ChapterLoad",
- "build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
- )
- let buildStart = CFAbsoluteTimeGetCurrent()
-
- do {
-
- let chapter = try self.buildChapter(
- spineIndex: spineIndex,
- availablePageRanges: availablePageRanges,
- diskSummary: diskSummary,
- context: context,
- layoutSnapshot: layoutSnapshot
- )
-
- RDEPUBBackgroundTrace.log(
- "ChapterLoad",
- "build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
- )
- store.insertChapter(chapter)
- let pc = RDEPUBRuntimePageCount(
- cacheKey: cacheKey,
- spineIndex: spineIndex,
- pageRanges: chapter.pageRanges,
- pageCount: chapter.pages.count,
- renderSignature: cacheKey.renderSignature
- )
- store.insertPageCount(pc, for: cacheKey)
- self.scheduleDeferredCFIMapBuildIfNeeded(
- for: chapter,
- cacheKey: cacheKey,
- store: store
- )
-
- let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
- store.endPendingChapterLoad(for: spineIndex)
- switch effectivePriority {
- case .navigation:
-
- let nextTarget = store.consumeNavigationTarget()
- if let target = nextTarget, target != spineIndex {
-
- store.markBuilding(false)
- self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
- self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
- return
- }
- store.markBuilding(false)
- self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
-
- case .preview:
- store.markBuilding(false)
- self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
-
- case .prefetch:
-
- store.removePrefetchTarget(spineIndex)
- store.markBuilding(false)
- self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
- }
- } catch {
- RDEPUBBackgroundTrace.log(
- "ChapterLoad",
- "build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
- )
- store.endPendingChapterLoad(for: spineIndex)
- store.markBuilding(false)
- self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
- }
- }
- }
-
- func loadChapterSynchronouslyForMigration(
- spineIndex: Int,
- store: RDEPUBChapterRuntimeStore?,
- layoutSnapshot: RDEPUBLayoutSnapshot? = nil
- ) throws -> RDEPUBRuntimeChapter {
- guard let context else {
- throw RDEPUBChapterLoadError.missingParser
- }
-
- if let cached = store?.chapterData(for: spineIndex) {
- if let store {
- scheduleDeferredCFIMapBuildIfNeeded(
- for: cached,
- cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
- store: store
- )
- }
- return cached
- }
-
- guard let store else {
- throw RDEPUBChapterLoadError.missingParser
- }
-
- if store.hasPendingChapterLoad(for: spineIndex) {
- var result: Result?
- let semaphore = DispatchSemaphore(value: 0)
- let registration = registerPendingLoad(
- spineIndex: spineIndex,
- priority: .navigation
- ) { pendingResult in
- result = pendingResult
- semaphore.signal()
- }
- if case .joined(let existingPriority, let effectivePriority) = registration {
- semaphore.wait()
- return try result!.get()
- }
- }
-
- store.assertNotOnChapterLoadQueue()
-
- let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
- var result: Result?
- let semaphore = DispatchSemaphore(value: 0)
- store.chapterLoadQueue.async {
- do {
- let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
- let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
- let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
- let diskSummary: RDEPUBChapterSummary?
- if precomputedPageRanges == nil {
- diskSummary = self.summaryDiskCache?.read(for: cacheKey)
- } else {
- diskSummary = nil
- }
- let chapter = try self.buildChapter(
- spineIndex: spineIndex,
- availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
- diskSummary: diskSummary,
- context: context,
- layoutSnapshot: snapshot
- )
- store.insertChapter(chapter)
- let pageCount = RDEPUBRuntimePageCount(
- cacheKey: cacheKey,
- spineIndex: spineIndex,
- pageRanges: chapter.pageRanges,
- pageCount: chapter.pages.count,
- renderSignature: cacheKey.renderSignature
- )
- store.insertPageCount(pageCount, for: cacheKey)
- self.scheduleDeferredCFIMapBuildIfNeeded(
- for: chapter,
- cacheKey: cacheKey,
- store: store
- )
- return chapter
- }
- result = .success(chapter)
- } catch {
- result = .failure(error)
- }
- semaphore.signal()
- }
- semaphore.wait()
- return try result!.get()
- }
-
- private func registerPendingLoad(
- spineIndex: Int,
- priority: LoadPriority,
- completion: @escaping LoadCompletion
- ) -> LoadRegistrationResult {
- pendingLoadsLock.lock()
- defer { pendingLoadsLock.unlock() }
-
- if var pending = pendingLoads[spineIndex] {
- let existingPriority = pending.priority
- pending.priority = LoadPriority.higherPriority(existingPriority, priority)
- pending.completions.append(completion)
- pendingLoads[spineIndex] = pending
- return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
- }
-
- pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
- return .created
- }
-
- private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
- pendingLoadsLock.lock()
- let priority = pendingLoads[spineIndex]?.priority
- pendingLoadsLock.unlock()
- return priority
- }
-
- private func resolvePendingLoad(spineIndex: Int, result: Result) {
- pendingLoadsLock.lock()
- let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
- pendingLoadsLock.unlock()
-
- guard !completions.isEmpty else { return }
-
- completions.forEach { $0(result) }
- }
-
- private func buildChapter(
- spineIndex: Int,
- availablePageRanges: [NSRange]?,
- diskSummary: RDEPUBChapterSummary? = nil,
- context: RDEPUBReaderContext,
- layoutSnapshot: RDEPUBLayoutSnapshot? = nil
- ) throws -> RDEPUBRuntimeChapter {
- guard let parser = context.parser,
- let publication = context.publication else {
- throw RDEPUBChapterLoadError.missingParser
- }
-
- let pageSize: CGSize
- let style: RDEPUBTextRenderStyle
- let layoutConfig: RDEPUBTextLayoutConfig
-
- if let snapshot = layoutSnapshot {
- pageSize = snapshot.pageSize
- style = snapshot.style
- layoutConfig = snapshot.layoutConfig
- } else {
- assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
- pageSize = context.currentTextPageSize()
- style = context.currentTextRenderStyle()
- layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
- }
-
- if let pageRanges = availablePageRanges {
-
- return try buildChapterFromCachedPageRanges(
- spineIndex: spineIndex,
- pageRanges: pageRanges,
- parser: parser,
- publication: publication,
- pageSize: pageSize,
- style: style,
- layoutConfig: layoutConfig,
- diskSummary: diskSummary,
- context: context
- )
- }
-
- let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
- guard let result = try builder.buildChapter(
- parser: parser,
- publication: publication,
- spineIndex: spineIndex,
- pageSize: pageSize,
- style: style
- ) else {
- throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
- }
-
- return try assembleRuntimeChapter(
- from: result.chapter,
- spineIndex: spineIndex,
- pageSize: pageSize,
- layoutConfig: layoutConfig,
- context: context,
- layoutSnapshot: layoutSnapshot
- )
- }
-
- private func buildChapterFromCachedPageRanges(
- spineIndex: Int,
- pageRanges: [NSRange],
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- pageSize: CGSize,
- style: RDEPUBTextRenderStyle,
- layoutConfig: RDEPUBTextLayoutConfig,
- diskSummary: RDEPUBChapterSummary? = nil,
- context: RDEPUBReaderContext
- ) throws -> RDEPUBRuntimeChapter {
- let spineItem = publication.spine[spineIndex]
- let href = spineItem.href
- let title = spineItem.title
- let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
- let rawHTML = try requireHTMLString(parser, href: href)
-
- let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
- href: href,
- title: title,
- rawHTML: rawHTML,
- baseURL: baseURL,
- style: style,
- resourceResolver: publication.resourceResolver,
- pageSize: pageSize,
- layoutConfig: layoutConfig
- )
- let renderer = context.resolvedTextRenderer()
- let rendered = try renderer.renderChapter(request: request)
-
- let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
- RDEPUBTextRendererSupport.normalizeReadingAttributes(
- in: typesetString, style: style, layoutConfig: layoutConfig
- )
-
- let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
- let effectivePageRanges: [NSRange]
- let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
-
- if sanitizedCachedRanges.count == pageRanges.count {
- effectivePageRanges = sanitizedCachedRanges
- metadataSource = diskSummary?.pageMetadataList
- } else {
- effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
- metadataSource = nil
- }
-
- let pages = buildPagesFromRanges(
- pageRanges: effectivePageRanges,
- typesetString: typesetString,
- spineIndex: spineIndex,
- href: href,
- title: title,
- metadataSource: metadataSource
- )
-
- let layouter = RDEPUBTextLayouter(
- attributedString: typesetString,
- pageSize: pageSize,
- config: layoutConfig
- )
-
- let offsetMap = RDEPUBChapterOffsetMap(
- fragmentOffsets: rendered.fragmentOffsets,
- pageStartOffsets: pages.map { $0.pageStartOffset },
- pageEndOffsets: pages.map { $0.pageEndOffset },
- cfiMap: diskSummary?.cfiMap,
- chapterText: typesetString.string
- )
-
- return RDEPUBRuntimeChapter(
- spineIndex: spineIndex,
- href: href,
- title: title,
- sourceAttributedString: nil,
- typesetAttributedString: typesetString,
- layouter: layouter,
- pageRanges: effectivePageRanges,
- pages: pages,
- chapterOffsetMap: offsetMap
- )
- }
-
- private func buildPagesFromRanges(
- pageRanges: [NSRange],
- typesetString: NSAttributedString,
- spineIndex: Int,
- href: String,
- title: String,
- metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
- ) -> [RDEPUBTextPage] {
- let totalPageCount = pageRanges.count
- return pageRanges.enumerated().map { (pageIndex, range) in
- let metadata: RDEPUBTextPageMetadata
- if let metaList = metadataSource, pageIndex < metaList.count {
-
- metadata = metaList[pageIndex].toPageMetadata()
- } else {
-
- metadata = inferPageMetadata(
- from: typesetString,
- range: range,
- isLastPage: pageIndex == totalPageCount - 1
- )
- }
- return RDEPUBTextPage(
- absolutePageIndex: -1,
- chapterIndex: 0,
- spineIndex: spineIndex,
- href: href,
- chapterTitle: title,
- pageIndexInChapter: pageIndex,
- totalPagesInChapter: totalPageCount,
- chapterContent: typesetString,
- contentRange: range,
- pageStartOffset: range.location,
- pageEndOffset: range.location + range.length - 1,
- metadata: metadata
- )
- }
- }
-
- private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
- guard contentLength > 0 else { return [] }
-
- return pageRanges.compactMap { range in
- guard range.location >= 0, range.location < contentLength else {
- return nil
- }
- let maxLength = contentLength - range.location
- let clampedLength = min(max(range.length, 0), maxLength)
- guard clampedLength > 0 else {
- return nil
- }
- return NSRange(location: range.location, length: clampedLength)
- }
- }
-
- private func inferPageMetadata(
- from string: NSAttributedString,
- range: NSRange,
- isLastPage: Bool
- ) -> RDEPUBTextPageMetadata {
- var attachmentRanges: [NSRange] = []
- var attachmentKinds: [RDEPUBTextAttachmentKind] = []
- var blockKinds: [RDEPUBTextBlockKind] = []
- var semanticHints: [RDEPUBTextSemanticHint] = []
- var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
- var trailingFragmentID: String? = nil
-
- string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
- if let rawValue = value as? String,
- let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
- attachmentRanges.append(attrRange)
- attachmentKinds.append(kind)
- }
- }
- string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
- if let rawValue = value as? String,
- let kind = RDEPUBTextBlockKind(rawValue: rawValue),
- !blockKinds.contains(kind) {
- blockKinds.append(kind)
- }
- }
- string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
- if let rawValue = value as? String {
- let hints = rawValue
- .split(separator: ",")
- .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
- for hint in hints where !semanticHints.contains(hint) {
- semanticHints.append(hint)
- }
- }
- }
- string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
- if let rawValue = value as? String,
- let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
- !attachmentPlacements.contains(placement) {
- attachmentPlacements.append(placement)
- }
- }
- string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
- if let fid = value as? String {
- trailingFragmentID = fid
- stop.pointee = true
- }
- }
-
- return RDEPUBTextPageMetadata(
- breakReason: isLastPage ? .chapterEnd : .frameLimit,
- blockRange: nil,
- attachmentRanges: attachmentRanges,
- attachmentKinds: attachmentKinds,
- blockKinds: blockKinds,
- semanticHints: semanticHints,
- attachmentPlacements: attachmentPlacements,
- trailingFragmentID: trailingFragmentID,
- diagnostics: []
- )
- }
-
- private func assembleRuntimeChapter(
- from chapter: RDEPUBTextChapter,
- spineIndex: Int,
- pageSize: CGSize,
- layoutConfig: RDEPUBTextLayoutConfig,
- context: RDEPUBReaderContext,
- layoutSnapshot: RDEPUBLayoutSnapshot? = nil
- ) throws -> RDEPUBRuntimeChapter {
- let layouter = RDEPUBTextLayouter(
- attributedString: chapter.attributedContent,
- pageSize: pageSize,
- config: layoutConfig
- )
-
- let offsetMap = RDEPUBChapterOffsetMap(
- fragmentOffsets: chapter.fragmentOffsets,
- pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
- pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
- cfiMap: chapter.cfiMap,
- chapterText: chapter.attributedContent.string
- )
-
- let pageRanges = chapter.pages.map { $0.contentRange }
- let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
- summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
-
- return RDEPUBRuntimeChapter(
- spineIndex: spineIndex,
- href: chapter.href,
- title: chapter.title,
- sourceAttributedString: nil,
- typesetAttributedString: chapter.attributedContent,
- layouter: layouter,
- pageRanges: pageRanges,
- pages: chapter.pages,
- chapterOffsetMap: offsetMap
- )
- }
-
- private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
- let style: RDEPUBTextRenderStyle
- let layoutConfig: RDEPUBTextLayoutConfig
- let lineHeightMultiple: CGFloat
-
- if let snapshot = layoutSnapshot {
- style = snapshot.style
- layoutConfig = snapshot.layoutConfig
- lineHeightMultiple = context.configuration.lineHeightMultiple
- } else {
- assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
- let pageSize = context.currentTextPageSize()
- style = context.currentTextRenderStyle()
- layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
- lineHeightMultiple = context.configuration.lineHeightMultiple
- }
-
- let renderSignature = [
- style.font.fontName,
- "\(style.font.pointSize)",
- "\(lineHeightMultiple)",
- "\(style.lineSpacing)",
- layoutConfig.cacheSignature,
- "\(RDEPUBChapterSummary.currentSchemaVersion)"
- ].joined(separator: "|")
-
- let contentHash = contentHashForSpineIndex(spineIndex, context: context)
-
- return RDEPUBChapterCacheKey(
- bookID: context.currentBookIdentifier ?? "",
- spineIndex: spineIndex,
- renderSignature: renderSignature,
- chapterContentHash: contentHash
- )
- }
-
- private func scheduleDeferredCFIMapBuildIfNeeded(
- for chapter: RDEPUBRuntimeChapter,
- cacheKey: RDEPUBChapterCacheKey,
- store: RDEPUBChapterRuntimeStore
- ) {
- guard chapter.chapterOffsetMap.cfiMap == nil,
- store.beginBuildingCFIMap(for: chapter.spineIndex) else {
- return
- }
-
- let spineIndex = chapter.spineIndex
- let href = chapter.href
- let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
- let chapterText = chapter.chapterOffsetMap.chapterText
-
- store.chapterLoadQueue.async {
- defer { store.endBuildingCFIMap(for: spineIndex) }
- guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
- let chapterText else {
- return
- }
-
- let cfiMap = self.makeCFIMap(
- href: href,
- spineIndex: spineIndex,
- fragmentOffsets: fragmentOffsets,
- rawHTML: rawHTML,
- chapterText: chapterText
- )
- chapter.updateCFIMap(cfiMap)
- self.summaryDiskCache?.write(
- summary: self.makeSummary(
- for: chapter.pages,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
- offsetMap: chapter.chapterOffsetMap,
- cacheKey: cacheKey
- ),
- for: cacheKey
- )
- DispatchQueue.main.async {
- self.onDeferredCFIMapReady?(spineIndex)
- }
- }
- }
-
- private func makeSummary(
- for pages: [RDEPUBTextPage],
- fragmentOffsets: [String: Int],
- offsetMap: RDEPUBChapterOffsetMap,
- cacheKey: RDEPUBChapterCacheKey
- ) -> RDEPUBChapterSummary {
- RDEPUBChapterSummary(
- pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
- pageCount: pages.count,
- fragmentOffsets: fragmentOffsets,
- cfiMap: offsetMap.cfiMap,
- renderSignature: cacheKey.renderSignature,
- schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
- chapterContentHash: cacheKey.chapterContentHash,
- pageMetadataList: pages.map { .from($0.metadata) }
- )
- }
-
- private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
- guard let parser = context.parser,
- let publication = context.publication else { return "" }
- let href = publication.spine[spineIndex].href
- guard let html = parser.htmlString(forRelativePath: href) else { return "" }
- return html.rd_sha256Hex
- }
-
- private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
- guard let html = parser.htmlString(forRelativePath: href) else {
- throw RDEPUBChapterLoadError.emptyChapterHref(href)
- }
- return html
- }
-
- private func makeCFIMap(
- href: String,
- spineIndex: Int,
- fragmentOffsets: [String: Int],
- rawHTML: String?,
- chapterText: String
- ) -> RDEPUBCFIMap {
- if let rawHTML {
- return RDEPUBCFITextNodeMapBuilder.makeMap(
- href: href,
- rawHTML: rawHTML,
- chapterText: chapterText,
- fragmentOffsets: fragmentOffsets
- )
- }
-
- let domPaths: [String: RDEPUBCFIPath] = [:]
- let markers = fragmentOffsets
- .sorted { $0.value < $1.value }
- .map { fragmentID, offset in
- let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
- href: href,
- fileIndex: spineIndex,
- chapterOffset: offset,
- fragmentID: fragmentID
- )
- return RDEPUBCFIMarker(
- cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
- chapterOffset: offset,
- fragmentID: fragmentID
- )
- }
- return RDEPUBCFIMap(
- href: href,
- markers: markers,
- recoveryMetadata: RDEPUBCFIRecoveryMetadata(
- domFingerprint: "",
- normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
- fragmentPathMap: domPaths
- )
- )
- }
-}
-
-private extension RDEPUBChapterLoader.LoadPriority {
-
- static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
- if lhs.rank >= rhs.rank {
- return lhs
- }
- return rhs
- }
-
- var rank: Int {
- switch self {
- case .prefetch:
- return 0
- case .preview:
- return 1
- case .navigation:
- return 2
- }
- }
-}
-
-enum RDEPUBChapterLoadError: LocalizedError {
-
- case missingParser
-
- case emptyChapter(spineIndex: Int)
-
- case emptyChapterHref(String)
-
- var errorDescription: String? {
- switch self {
- case .missingParser:
- return "章节加载失败:缺少解析上下文。"
- case .emptyChapter(let spineIndex):
- return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
- case .emptyChapterHref(let href):
- return "章节加载失败:未找到章节资源 \(href)。"
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift
deleted file mode 100644
index 6a33766..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-import Foundation
-
-public struct RDEPUBChapterLocation: Codable, Equatable {
-
- public var spineIndex: Int
-
- public var chapterOffset: Int
-
- public var fragmentID: String?
-
- public var progressionInChapter: Double?
-
- public var schemaVersion: Int
-
- public init(
- spineIndex: Int,
- chapterOffset: Int,
- fragmentID: String? = nil,
- progressionInChapter: Double? = nil,
- schemaVersion: Int = 2
- ) {
- self.spineIndex = spineIndex
- self.chapterOffset = chapterOffset
- self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
- self.progressionInChapter = progressionInChapter
- self.schemaVersion = schemaVersion
- }
-
- var isFallbackEstimate: Bool { schemaVersion == 1 }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift
deleted file mode 100644
index af1323f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-import Foundation
-
-final class RDEPUBChapterOffsetMap {
-
- let fragmentOffsets: [String: Int]
-
- let pageStartOffsets: [Int]
-
- let pageEndOffsets: [Int]
-
- private let cfiMapLock = NSLock()
-
- private var _cfiMap: RDEPUBCFIMap?
-
- var cfiMap: RDEPUBCFIMap? {
- cfiMapLock.lock()
- defer { cfiMapLock.unlock() }
- return _cfiMap
- }
-
- let chapterText: String?
-
- init(
- fragmentOffsets: [String: Int],
- pageStartOffsets: [Int],
- pageEndOffsets: [Int],
- cfiMap: RDEPUBCFIMap?,
- chapterText: String?
- ) {
- self.fragmentOffsets = fragmentOffsets
- self.pageStartOffsets = pageStartOffsets
- self.pageEndOffsets = pageEndOffsets
- self._cfiMap = cfiMap
- self.chapterText = chapterText
- }
-
- func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
- cfiMapLock.lock()
- _cfiMap = cfiMap
- cfiMapLock.unlock()
- }
-
- func chapterOffset(forFragmentID fragmentID: String) -> Int? {
- return fragmentOffsets[fragmentID]
- }
-
- func chapterOffset(forCFI rawCFI: String?) -> Int? {
- guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
- let resolved = RDEPUBCFIResolver.resolve(cfi)
- let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
- return RDEPUBCFIRecoveryEngine.recover(
- cfi: cfi,
- cfiMap: cfiMap,
- chapterText: chapterText,
- fragmentOffsets: fragmentOffsets,
- fallbackOffset: resolved.chapterOffset,
- lastOffset: lastOffset
- )?.chapterOffset
- }
-
- func pageIndex(forChapterOffset offset: Int) -> Int? {
- for i in 0..= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
- return i
- }
- }
- return nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift
deleted file mode 100644
index 7b8efc7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-import UIKit
-
-final class RDEPUBChapterRuntimeStore {
-
- private let chapterDataCache = RDEPUBChapterDataCache()
-
- private let pageCountCache = RDEPUBPageCountCache()
-
- let imageCache = NSCache()
-
- let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated)
-
- private let chapterLoadQueueKey = DispatchSpecificKey()
-
- // M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
- // (verified by audit of all 7 access points). They are not protected by locks unlike
- // navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
- // access remains main-thread-only. Do NOT access from chapterLoadQueue.
- private(set) var currentSpineIndex: Int?
-
- private(set) var windowSpineIndices: [Int] = []
-
- private var pendingNavigationTarget: Int?
-
- private let navigationLock = NSLock()
-
- private var pendingPrefetchTargets: Set = []
-
- private let prefetchLock = NSLock()
-
- private(set) var isBuilding: Bool = false
-
- private let buildingLock = NSLock()
-
- private var buildingCFIMapSpineIndices: Set = []
-
- private let cfiMapLock = NSLock()
-
- private var pendingChapterLoadSpineIndices: Set = []
-
- private let pendingChapterLoadLock = NSLock()
-
- init() {
-
- imageCache.countLimit = 50
- imageCache.totalCostLimit = 104_857_600 // 100 MB
-
- chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
- }
-
- func assertNotOnChapterLoadQueue() {
- dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
- }
-
- func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
- return chapterDataCache[spineIndex]
- }
-
- func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
- return pageCountCache[key]
- }
-
- func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
- chapterDataCache[chapter.spineIndex] = chapter
- RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
- }
-
- func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
- pageCountCache[key] = pc
- }
-
- func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
- currentSpineIndex = spineIndex
- let radius = max(0, windowRadius)
- let lowerBound = max(0, spineIndex - radius)
- let upperBound = min(totalSpineCount - 1, spineIndex + radius)
- guard lowerBound <= upperBound else {
- windowSpineIndices = [spineIndex]
- return
- }
- windowSpineIndices = Array(lowerBound...upperBound)
- }
-
- func evictableSpineIndices() -> [Int] {
- let windowSet = Set(windowSpineIndices)
- return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
- }
-
- func evict(spineIndex: Int) {
- chapterDataCache.remove(spineIndex: spineIndex)
- pageCountCache.remove(forSpineIndex: spineIndex)
- }
-
- func evictAllExceptCurrent() {
- guard let current = currentSpineIndex else {
- chapterDataCache.removeAll()
- pageCountCache.removeAll()
- return
- }
- let currentChapter = chapterDataCache[current]
- chapterDataCache.removeAll()
- if let ch = currentChapter {
- chapterDataCache[current] = ch
- }
-
- pageCountCache.removeAll()
- }
-
- func handleMemoryWarning() {
- evictAllExceptCurrent()
- imageCache.removeAllObjects()
- }
-
- func setNavigationTarget(spineIndex: Int) {
- navigationLock.lock()
- pendingNavigationTarget = spineIndex
- navigationLock.unlock()
- }
-
- func consumeNavigationTarget() -> Int? {
- navigationLock.lock()
- let target = pendingNavigationTarget
- pendingNavigationTarget = nil
- navigationLock.unlock()
- return target
- }
-
- func addPrefetchTarget(_ spineIndex: Int) {
- prefetchLock.lock()
- pendingPrefetchTargets.insert(spineIndex)
- prefetchLock.unlock()
- }
-
- func removePrefetchTarget(_ spineIndex: Int) {
- prefetchLock.lock()
- pendingPrefetchTargets.remove(spineIndex)
- prefetchLock.unlock()
- }
-
- func clearPrefetchTargets() {
- prefetchLock.lock()
- pendingPrefetchTargets.removeAll()
- prefetchLock.unlock()
- }
-
- func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
- prefetchLock.lock()
- let has = pendingPrefetchTargets.contains(spineIndex)
- prefetchLock.unlock()
- return has
- }
-
- func markBuilding(_ building: Bool) {
- buildingLock.lock()
- isBuilding = building
- buildingLock.unlock()
- }
-
- func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
- pendingChapterLoadLock.lock()
- defer { pendingChapterLoadLock.unlock() }
- return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
- }
-
- func endPendingChapterLoad(for spineIndex: Int) {
- pendingChapterLoadLock.lock()
- pendingChapterLoadSpineIndices.remove(spineIndex)
- pendingChapterLoadLock.unlock()
- }
-
- func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
- pendingChapterLoadLock.lock()
- let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
- pendingChapterLoadLock.unlock()
- return hasPendingLoad
- }
-
- func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
- cfiMapLock.lock()
- defer { cfiMapLock.unlock() }
- let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
- return inserted
- }
-
- func endBuildingCFIMap(for spineIndex: Int) {
- cfiMapLock.lock()
- buildingCFIMapSpineIndices.remove(spineIndex)
- cfiMapLock.unlock()
- }
-
- func invalidateAllForSettingsChange() {
- RDEPUBMemoryProbe.log("settingsChangeInvalidateAll")
- chapterDataCache.removeAll()
- pageCountCache.removeAll()
- imageCache.removeAllObjects()
- cfiMapLock.lock()
- buildingCFIMapSpineIndices.removeAll()
- cfiMapLock.unlock()
- pendingChapterLoadLock.lock()
- pendingChapterLoadSpineIndices.removeAll()
- pendingChapterLoadLock.unlock()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift
deleted file mode 100644
index e0a5a79..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift
+++ /dev/null
@@ -1,228 +0,0 @@
-import Foundation
-
-final class RDEPUBChapterSummaryDiskCache {
-
- private let cacheDirectory: URL
-
- private let fileManager = FileManager.default
-
- private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility)
-
- init(cacheDirectory: URL) {
- self.cacheDirectory = cacheDirectory
- try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
- }
-
- func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
- queue.async {
- self.writeImmediately(summary: summary, for: key)
- }
- }
-
- func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
- queue.sync {
- self.writeImmediately(summary: summary, for: key)
- }
- }
-
- func flushPendingWrites() {
- queue.sync { }
- }
-
- func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
- let fileURL = self.fileURL(for: key)
- let data: Data
- do {
- data = try Data(contentsOf: fileURL)
- } catch {
- let nsError = error as NSError
- if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
-
- } else {
- #if DEBUG
- print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
- #endif
- }
- return nil
- }
- do {
- return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
- } catch {
- #if DEBUG
- print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
- #endif
- return nil
- }
- }
-
- func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
- summaries: [Int: RDEPUBChapterSummary],
- mapBuilder: RDEPUBBookPageMap.Builder
- ) {
- var summaries: [Int: RDEPUBChapterSummary] = [:]
- var mapBuilder = RDEPUBBookPageMap.Builder()
-
- for item in keys {
- if let summary = read(for: item.key) {
- summaries[item.spineIndex] = summary
- mapBuilder.add(
- spineIndex: item.spineIndex,
- href: item.href,
- title: item.title,
- pageCount: summary.pageCount,
- fragmentOffsets: summary.fragmentOffsets
- )
- }
- }
- return (summaries, mapBuilder)
- }
-
- func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
- for key in keys {
- if read(for: key) == nil {
- return false
- }
- }
- return true
- }
-
- func removeAll() {
- removeFiles(matching: { _ in true })
- }
-
- func removeAll(forBookID bookID: String) {
- let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
- removeFiles { $0.hasPrefix(bookPrefix + "__") }
- }
-
- func removeAll(forRenderSignature renderSignature: String) {
- let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
- removeFiles { $0.contains(renderPrefix) }
- }
-
- var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
- guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
- return (0, 0)
- }
- var count = 0
- var totalBytes: Int64 = 0
- for fileURL in files where fileURL.pathExtension == "json" {
- count += 1
- if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
- totalBytes += Int64(size)
- }
- }
- return (count, totalBytes)
- }
-
- private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
- let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
- let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
- let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
- let digest = rawKey.rd_sha256Hex
- return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
- }
-
- private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
- let fileURL = self.fileURL(for: key)
- let tmpURL = fileURL.appendingPathExtension("tmp")
- do {
- let data = try JSONEncoder().encode(summary)
- try data.write(to: tmpURL)
- if fileManager.fileExists(atPath: fileURL.path) {
- _ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
- } else {
- try fileManager.moveItem(at: tmpURL, to: fileURL)
- }
- } catch {
- #if DEBUG
- print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
- #endif
- try? fileManager.removeItem(at: tmpURL)
- }
- }
-
- private func removeFiles(matching predicate: (String) -> Bool) {
- guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
- for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
- try? fileManager.removeItem(at: fileURL)
- }
- }
-
- private static func cacheNamespacePrefix(for rawValue: String) -> String {
- rawValue.rd_sha256Hex.prefix(12).lowercased()
- }
-}
-
-struct RDEPUBChapterSummary: Codable {
-
- let pageRanges: [RangeData]
-
- let pageCount: Int
-
- let fragmentOffsets: [String: Int]
-
- let cfiMap: RDEPUBCFIMap?
-
- let renderSignature: String
-
- let schemaVersion: Int
-
- let chapterContentHash: String
-
- let pageMetadataList: [PageMetadataSummary]
-
- static let currentSchemaVersion = 17
-
- struct RangeData: Codable {
-
- let location: Int
-
- let length: Int
-
- var nsRange: NSRange { NSRange(location: location, length: length) }
- }
-
- struct PageMetadataSummary: Codable {
-
- let breakReason: String
-
- let attachmentRanges: [RangeData]
-
- let attachmentKinds: [String]
-
- let blockKinds: [String]
-
- let semanticHints: [String]
-
- let attachmentPlacements: [String]
-
- let trailingFragmentID: String?
-
- func toPageMetadata() -> RDEPUBTextPageMetadata {
- RDEPUBTextPageMetadata(
- breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
- blockRange: nil,
- attachmentRanges: attachmentRanges.map { $0.nsRange },
- attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
- blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
- semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
- attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
- trailingFragmentID: trailingFragmentID,
- diagnostics: []
- )
- }
-
- static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
- PageMetadataSummary(
- breakReason: metadata.breakReason.rawValue,
- attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
- attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
- blockKinds: metadata.blockKinds.map { $0.rawValue },
- semanticHints: metadata.semanticHints.map { $0.rawValue },
- attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
- trailingFragmentID: metadata.trailingFragmentID
- )
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift
deleted file mode 100644
index 7d23fab..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift
+++ /dev/null
@@ -1,679 +0,0 @@
-import Foundation
-
-final class RDEPUBChapterWarmupOrchestrator {
-
- private unowned let context: RDEPUBReaderContext
-
- private unowned let store: RDEPUBChapterRuntimeStore
-
- private unowned let loader: RDEPUBChapterLoader
-
- private unowned let presentationRuntime: RDEPUBPresentationRuntime
-
- private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
-
- private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
-
- private unowned let jumpSessionManager: RDEPUBJumpSessionManager
-
- private let refreshVisibleContentPreservingLocation: () -> Void
-
- private let asyncLoadStateLock = NSLock()
-
- private var asynchronouslyPreparingSpineIndices: Set = []
-
- private var isExtendingPartialBookPageMap = false
-
- private let prepareRequestStateLock = NSLock()
-
- private var pendingPreparePageNumbers: Set = []
-
- private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
-
- private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
-
- private static let upcomingChapterLookaheadCount = 2
-
- init(
- context: RDEPUBReaderContext,
- store: RDEPUBChapterRuntimeStore,
- loader: RDEPUBChapterLoader,
- presentationRuntime: RDEPUBPresentationRuntime,
- locationCoordinator: RDEPUBReaderLocationCoordinator,
- backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
- jumpSessionManager: RDEPUBJumpSessionManager,
- refreshVisibleContentPreservingLocation: @escaping () -> Void
- ) {
- self.context = context
- self.store = store
- self.loader = loader
- self.presentationRuntime = presentationRuntime
- self.locationCoordinator = locationCoordinator
- self.backgroundPriorityManager = backgroundPriorityManager
- self.jumpSessionManager = jumpSessionManager
- self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
- }
-
- @discardableResult
- func prepareOnDemandChapter(
- forAbsolutePageNumber pageNumber: Int,
- allowSynchronousLoad: Bool = true,
- completion: ((Bool) -> Void)? = nil
- ) -> Bool {
- guard let bookPageMap = context.bookPageMap,
- let publication = context.publication else {
- return false
- }
- let absolutePageIndex = pageNumber - 1
- guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
- return false
- }
- let chapterReady = store.chapterData(for: spineIndex) != nil
-
- if let debouncedResult = debouncedPrepareResult(
- pageNumber: pageNumber,
- spineIndex: spineIndex,
- chapterReady: chapterReady,
- allowSynchronousLoad: allowSynchronousLoad
- ) {
- return debouncedResult
- }
-
- store.setCurrentChapter(
- spineIndex: spineIndex,
- totalSpineCount: publication.spine.count,
- windowRadius: context.configuration.chapterWindowRadius
- )
- if !chapterReady {
- guard allowSynchronousLoad else {
- scheduleAsynchronousChapterPreparation(
- spineIndex: spineIndex,
- triggerPageNumber: pageNumber,
- completion: completion
- )
- return false
- }
- do {
- _ = try loader.loadChapterSynchronouslyForMigration(
- spineIndex: spineIndex,
- store: store
- )
- } catch {
- clearPendingPreparePageNumber(pageNumber)
- return false
- }
- }
-
- markPrepareResolved(pageNumber)
- completion?(true)
- maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
- scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
- return true
- }
-
- func extendPartialBookPageMapIfNeeded(
- currentPageNumber: Int,
- minimumTrailingPages: Int = 2,
- batchChapterCount: Int = 3
- ) {
- guard let publication = context.publication,
- let currentMap = context.bookPageMap else {
- return
- }
-
- let buildableSpineIndices = buildableSpineIndices(in: publication)
- guard currentMap.totalChapters < buildableSpineIndices.count else {
- return
- }
-
- let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
- let isNearStart = currentPageNumber <= minimumTrailingPages
-
- var spineIndicesToAppend: [Int] = []
- if isNearEnd {
- let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
- spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
- } else if isNearStart {
- let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
- let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
- spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
- } else {
- return
- }
-
- guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
- return
- }
-
-
- let currentLocation = locationCoordinator.currentVisibleLocation()
- let loadedChaptersLock = NSLock()
- var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
- let group = DispatchGroup()
-
- for spineIndex in spineIndicesToAppend {
- group.enter()
- loader.loadChapter(
- spineIndex: spineIndex,
- store: store,
- priority: .prefetch
- ) { result in
- defer { group.leave() }
- switch result {
- case .success(let chapter):
- loadedChaptersLock.lock()
- loadedChapters[spineIndex] = chapter
- loadedChaptersLock.unlock()
- case .failure:
- break
- }
- }
- }
-
- group.notify(queue: .main) { [weak self] in
- guard let self else { return }
- defer { self.endPartialBookPageMapExtension() }
- // Use live position values rather than the stale captured values,
- // since the user may have turned several pages since the extension began.
- let livePageNumber = max(self.context.readerView?.currentPage ?? 0, 0) + 1
- let liveLocation = self.locationCoordinator.currentVisibleLocation()
- self.applyAsyncPartialBookPageMapExtension(
- currentPageNumber: livePageNumber,
- currentLocation: liveLocation,
- currentMap: currentMap,
- loadedChapters: loadedChapters
- )
- }
- }
-
- func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
- guard context.publication != nil else { return }
-
- store.setCurrentChapter(
- spineIndex: anchorSpineIndex,
- totalSpineCount: totalSpineCount,
- windowRadius: context.configuration.chapterWindowRadius
- )
-
- let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
- guard !forwardTargets.isEmpty else { return }
-
- for spineIndex in forwardTargets {
- if store.chapterData(for: spineIndex) != nil {
- appendLoadedForwardChaptersToCurrentPageMapIfPossible()
- continue
- }
- guard shouldSchedulePrefetch(for: spineIndex) else { continue }
-
- store.addPrefetchTarget(spineIndex)
- loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
- guard let self, case .success = result else { return }
- self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
- }
- }
- }
-
- @discardableResult
- func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
- guard context.bookPageMap != nil,
- let publication = context.publication,
- let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
- return false
- }
-
- let currentSpineIndex = locationCoordinator.currentVisibleLocation()
- .flatMap { context.normalizedSpineIndex(for: $0) }
- let isDistantJump = if let current = currentSpineIndex {
- abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
- } else {
- false
- }
-
- if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
- if isDistantJump {
- jumpSessionManager.createSession(
- anchorSpineIndex: targetSpineIndex,
- reason: .tableOfContentsJump,
- totalSpineCount: publication.spine.count
- )
- }
- return true
- }
-
- if context.pendingPageMapUpdates.contains(where: { update in
- update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
- }) {
- presentationRuntime.commitPendingPageMapUpdateIfNeeded()
- if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
- if isDistantJump {
- jumpSessionManager.createSession(
- anchorSpineIndex: targetSpineIndex,
- reason: .tableOfContentsJump,
- totalSpineCount: publication.spine.count
- )
- }
- return true
- }
- }
-
- let buildableIndices = buildableSpineIndices(in: publication)
- guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
- return false
- }
-
- let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
- context.configuration.onDemandChapterWindowSize
- )
- let chapters = loadPartialWindowChapters(
- around: anchorPosition,
- in: buildableIndices,
- targetSpineIndex: targetSpineIndex,
- windowSize: normalizedWindowSize
- )
- guard !chapters.isEmpty else { return false }
-
- store.setCurrentChapter(
- spineIndex: targetSpineIndex,
- totalSpineCount: publication.spine.count,
- windowRadius: context.configuration.chapterWindowRadius
- )
- let partialMap = makePartialPageMap(from: chapters)
- context.bookPageMap = partialMap
- context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
- context.readerView?.reloadData()
-
- if isDistantJump {
- jumpSessionManager.createSession(
- anchorSpineIndex: targetSpineIndex,
- reason: .tableOfContentsJump,
- totalSpineCount: publication.spine.count
- )
- backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
- }
-
- return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
- }
-
- func clear() {
- asyncLoadStateLock.lock()
- asynchronouslyPreparingSpineIndices.removeAll()
- isExtendingPartialBookPageMap = false
- asyncLoadStateLock.unlock()
- prepareRequestStateLock.lock()
- pendingPreparePageNumbers.removeAll()
- recentPrepareTimestamps.removeAll()
- prepareRequestStateLock.unlock()
- }
-
- private func applyAsyncPartialBookPageMapExtension(
- currentPageNumber: Int,
- currentLocation: RDEPUBLocation?,
- currentMap: RDEPUBBookPageMap,
- loadedChapters: [Int: RDEPUBRuntimeChapter]
- ) {
- let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
- guard let chapter = loadedChapters[spineIndex] else { return nil }
- return RDEPUBBookPageMapEntry(
- spineIndex: chapter.spineIndex,
- href: chapter.href,
- title: chapter.title,
- pageCount: chapter.pages.count,
- absolutePageStart: 0,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
- )
- }
- guard !appendedEntries.isEmpty else {
- return
- }
-
- let combinedEntries = (currentMap.entries.map {
- RDEPUBBookPageMapEntry(
- spineIndex: $0.spineIndex,
- href: $0.href,
- title: $0.title,
- pageCount: $0.pageCount,
- absolutePageStart: 0,
- fragmentOffsets: $0.fragmentOffsets
- )
- } + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
-
- var absolutePageStart = 0
- let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
- let normalized = RDEPUBBookPageMapEntry(
- spineIndex: entry.spineIndex,
- href: entry.href,
- title: entry.title,
- pageCount: entry.pageCount,
- absolutePageStart: absolutePageStart,
- fragmentOffsets: entry.fragmentOffsets
- )
- absolutePageStart += entry.pageCount
- return normalized
- }
-
- let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
- presentationRuntime.queueExtendedPartialPageMap(
- newMap,
- currentPageNumber: currentPageNumber,
- currentLocation: currentLocation
- )
- }
-
- private func scheduleAsynchronousChapterPreparation(
- spineIndex: Int,
- triggerPageNumber: Int,
- completion: ((Bool) -> Void)?
- ) {
- guard beginAsynchronousChapterPreparation(for: spineIndex) else {
- return
- }
- loader.loadChapter(
- spineIndex: spineIndex,
- store: store,
- priority: .preview
- ) { [weak self] result in
- guard let self else { return }
- self.endAsynchronousChapterPreparation(for: spineIndex)
- switch result {
- case .success:
- self.markPrepareResolved(triggerPageNumber)
- completion?(true)
- self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
- self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
- case .failure(let error):
- self.clearPendingPreparePageNumber(triggerPageNumber)
- completion?(false)
- }
- }
- }
-
- private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
- store.setCurrentChapter(
- spineIndex: spineIndex,
- totalSpineCount: totalSpineCount,
- windowRadius: context.configuration.chapterWindowRadius
- )
-
- // Keep chapters that maybePrefetchUpcomingChapters is responsible for,
- // otherwise the two policies evict/rebuild the same chapter in a loop.
- let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
- for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
- store.evict(spineIndex: evictable)
- }
-
- for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
- guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
- store.addPrefetchTarget(adjacentSpineIndex)
- loader.loadChapter(
- spineIndex: adjacentSpineIndex,
- store: store,
- priority: .prefetch
- ) { [weak self] result in
- guard let self, case .success = result else { return }
- self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
- }
- }
- }
-
- private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set {
- guard let publication = context.publication else { return [] }
- let buildableIndices = buildableSpineIndices(in: publication)
- guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
- return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
- }
-
- private func maybePrefetchUpcomingChapters(
- aroundAbsolutePageNumber pageNumber: Int,
- in bookPageMap: RDEPUBBookPageMap,
- threshold: Int = 3,
- lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
- ) {
- guard let publication = context.publication else { return }
- let absolutePageIndex = pageNumber - 1
- guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
- let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
- let chapter = store.chapterData(for: spineIndex) else {
- return
- }
-
- let remainingPages = chapter.pages.count - localPageIndex - 1
- guard remainingPages <= threshold else { return }
-
- let buildableIndices = buildableSpineIndices(in: publication)
- guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
-
- let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
- for targetSpineIndex in targets {
- guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
- store.addPrefetchTarget(targetSpineIndex)
- loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
- guard let self, case .success = result else { return }
- self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
- }
- }
- }
-
- private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
- minimumTrailingPages: Int = 2
- ) {
- guard let publication = context.publication,
- let currentMap = context.bookPageMap,
- let readerView = context.readerView,
- let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
- return
- }
-
- let currentPageNumber = max(readerView.currentPage + 1, 1)
- let trailingPages = currentMap.totalPages - currentPageNumber
- guard trailingPages <= minimumTrailingPages else { return }
-
- let buildableIndices = buildableSpineIndices(in: publication)
- var appendedEntries: [RDEPUBBookPageMapEntry] = []
- var projectedTotalPages = currentMap.totalPages
- for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
- guard let chapter = store.chapterData(for: spineIndex) else { break }
- appendedEntries.append(
- RDEPUBBookPageMapEntry(
- spineIndex: chapter.spineIndex,
- href: chapter.href,
- title: chapter.title,
- pageCount: chapter.pages.count,
- absolutePageStart: 0,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
- )
- )
- projectedTotalPages += chapter.pages.count
- if projectedTotalPages - currentPageNumber > minimumTrailingPages {
- break
- }
- }
-
- guard !appendedEntries.isEmpty else { return }
-
- let existingEntries = currentMap.entries.map {
- RDEPUBBookPageMapEntry(
- spineIndex: $0.spineIndex,
- href: $0.href,
- title: $0.title,
- pageCount: $0.pageCount,
- absolutePageStart: 0,
- fragmentOffsets: $0.fragmentOffsets
- )
- }
-
- var absolutePageStart = 0
- let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
- let normalizedEntry = RDEPUBBookPageMapEntry(
- spineIndex: entry.spineIndex,
- href: entry.href,
- title: entry.title,
- pageCount: entry.pageCount,
- absolutePageStart: absolutePageStart,
- fragmentOffsets: entry.fragmentOffsets
- )
- absolutePageStart += entry.pageCount
- return normalizedEntry
- }
-
- let newMap = RDEPUBBookPageMap(entries: newEntries)
- guard newMap.totalPages > currentMap.totalPages else { return }
-
-
- presentationRuntime.queueForwardAppendedPageMap(newMap)
- }
-
- private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
- guard store.chapterData(for: spineIndex) == nil else { return false }
- guard !store.hasPrefetchTarget(spineIndex) else { return false }
- guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
- return true
- }
-
- private func debouncedPrepareResult(
- pageNumber: Int,
- spineIndex: Int,
- chapterReady: Bool,
- allowSynchronousLoad: Bool
- ) -> Bool? {
- prepareRequestStateLock.lock()
- defer { prepareRequestStateLock.unlock() }
-
- let now = CFAbsoluteTimeGetCurrent()
- recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
-
- if !allowSynchronousLoad && !chapterReady {
- let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
- if !inserted {
- return false
- }
- return nil
- }
-
- pendingPreparePageNumbers.remove(pageNumber)
- if let lastTimestamp = recentPrepareTimestamps[pageNumber],
- now - lastTimestamp <= prepareRequestDebounceInterval {
- return chapterReady
- }
-
- recentPrepareTimestamps[pageNumber] = now
- return nil
- }
-
- private func markPrepareResolved(_ pageNumber: Int) {
- prepareRequestStateLock.lock()
- pendingPreparePageNumbers.remove(pageNumber)
- recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
- prepareRequestStateLock.unlock()
- }
-
- private func clearPendingPreparePageNumber(_ pageNumber: Int) {
- prepareRequestStateLock.lock()
- pendingPreparePageNumbers.remove(pageNumber)
- prepareRequestStateLock.unlock()
- }
-
- private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
- guard let readerView = context.readerView,
- let bookPageMap = context.bookPageMap else {
- return
- }
- if readerView.isPageCurlTransitioning {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
- self?.refreshVisibleContentIfNeeded(
- afterPreparing: spineIndex,
- triggerPageNumber: triggerPageNumber
- )
- }
- return
- }
- let visiblePageNumber = readerView.currentPage + 1
- if visiblePageNumber == triggerPageNumber {
- refreshVisibleContentPreservingLocation()
- return
- }
- guard visiblePageNumber > 0,
- let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
- visibleSpineIndex == spineIndex else {
- return
- }
- refreshVisibleContentPreservingLocation()
- }
-
- // Intentional synchronous path: called from TOC distant jumps where
- // the user expects immediate navigation. The loading indicator is shown
- // by the caller. Do NOT convert to async without UX consideration.
- private func loadPartialWindowChapters(
- around anchorPosition: Int,
- in buildableSpineIndices: [Int],
- targetSpineIndex: Int,
- windowSize: Int
- ) -> [RDEPUBRuntimeChapter] {
- let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
- let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
- let startIndex = max(0, upperBound - max(windowSize, 1))
- let window = Array(buildableSpineIndices[startIndex.. RDEPUBBookPageMap {
- var builder = RDEPUBBookPageMap.Builder()
- for chapter in chapters {
- builder.add(
- spineIndex: chapter.spineIndex,
- href: chapter.href,
- title: chapter.title,
- pageCount: chapter.pages.count,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
- )
- }
- return builder.build()
- }
-
- private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
- publication.spine.indices.filter { index in
- let item = publication.spine[index]
- return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
- }
- }
-
- private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
- asyncLoadStateLock.lock()
- defer { asyncLoadStateLock.unlock() }
- return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
- }
-
- private func endAsynchronousChapterPreparation(for spineIndex: Int) {
- asyncLoadStateLock.lock()
- asynchronouslyPreparingSpineIndices.remove(spineIndex)
- asyncLoadStateLock.unlock()
- }
-
- private func beginPartialBookPageMapExtension() -> Bool {
- asyncLoadStateLock.lock()
- defer { asyncLoadStateLock.unlock() }
- guard !isExtendingPartialBookPageMap else { return false }
- isExtendingPartialBookPageMap = true
- return true
- }
-
- private func endPartialBookPageMapExtension() {
- asyncLoadStateLock.lock()
- isExtendingPartialBookPageMap = false
- asyncLoadStateLock.unlock()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift
deleted file mode 100644
index df58a84..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-import Foundation
-
-final class RDEPUBPageCountCache {
-
- private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
-
- private let lock = NSLock()
-
- subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
- get {
- lock.lock()
- defer { lock.unlock() }
- return storage[key]
- }
- set {
- lock.lock()
- defer { lock.unlock() }
- storage[key] = newValue
- }
- }
-
- func remove(forSpineIndex spineIndex: Int) {
- lock.lock()
- defer { lock.unlock() }
- storage = storage.filter { $0.value.spineIndex != spineIndex }
- }
-
- func removeAll() {
- lock.lock()
- defer { lock.unlock() }
- storage.removeAll()
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift
deleted file mode 100644
index af9d86b..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-import Foundation
-
-struct RDEPUBResolvedPage {
-
- let page: RDEPUBTextPage
-
- let chapter: RDEPUBRuntimeChapter
-
- let chapterIndex: Int
-}
-
-final class RDEPUBPageResolver {
-
- private unowned let context: RDEPUBReaderContext
-
- private let store: RDEPUBChapterRuntimeStore
-
- init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
- self.context = context
- self.store = store
- }
-
- func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
- guard let bookPageMap = context.bookPageMap,
- let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
- let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
- let chapter = store.chapterData(for: spineIndex),
- chapter.pages.indices.contains(localPageIndex),
- let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
- return nil
- }
-
- var page = chapter.pages[localPageIndex]
- page.absolutePageIndex = absolutePageIndex
- page.chapterIndex = chapterIndex
- page.pageIndexInChapter = localPageIndex
- page.totalPagesInChapter = chapter.pages.count
- return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift
deleted file mode 100644
index 9cfddd3..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import Foundation
-
-final class RDEPUBRuntimeChapter {
-
- let spineIndex: Int
-
- let href: String
-
- let title: String
-
- var sourceAttributedString: NSAttributedString?
-
- let typesetAttributedString: NSAttributedString
-
- let layouter: RDEPUBTextLayouter
-
- let pageRanges: [NSRange]
-
- let pages: [RDEPUBTextPage]
-
- let chapterOffsetMap: RDEPUBChapterOffsetMap
-
- init(
- spineIndex: Int,
- href: String,
- title: String,
- sourceAttributedString: NSAttributedString?,
- typesetAttributedString: NSAttributedString,
- layouter: RDEPUBTextLayouter,
- pageRanges: [NSRange],
- pages: [RDEPUBTextPage],
- chapterOffsetMap: RDEPUBChapterOffsetMap
- ) {
- self.spineIndex = spineIndex
- self.href = href
- self.title = title
- self.sourceAttributedString = sourceAttributedString
- self.typesetAttributedString = typesetAttributedString
- self.layouter = layouter
- self.pageRanges = pageRanges
- self.pages = pages
- self.chapterOffsetMap = chapterOffsetMap
- }
-
- func releaseSourceText() {
- sourceAttributedString = nil
- }
-
- func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
- chapterOffsetMap.updateCFIMap(cfiMap)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift
deleted file mode 100644
index c62966f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-import Foundation
-
-struct RDEPUBRuntimePageCount {
-
- let cacheKey: RDEPUBChapterCacheKey
-
- let spineIndex: Int
-
- let pageRanges: [NSRange]
-
- let pageCount: Int
-
- let renderSignature: String
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift
deleted file mode 100644
index 7572b04..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift
+++ /dev/null
@@ -1,252 +0,0 @@
-import Foundation
-
-struct RDEPUBBackgroundCoverageSegment {
-
- let lowerSpineIndex: Int
-
- let upperSpineIndex: Int
-
- let pageMap: RDEPUBBookPageMap
-
- let resolvedSpineIndices: Set
-
- let generatedAt: CFAbsoluteTime
-
- let renderSignature: String
-
- let estimatedMemoryBytes: Int
-
- func contains(spineIndex: Int) -> Bool {
- spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
- }
-
- func distance(to spineIndex: Int) -> Int {
- if contains(spineIndex: spineIndex) { return 0 }
- return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
- }
-}
-
-struct RDEPUBBackgroundCoverageStorePolicy {
-
- let maxResidentSegments: Int
-
- let maxChaptersPerSegment: Int
-
- let memoryBudgetBytes: Int
-
- static let `default` = RDEPUBBackgroundCoverageStorePolicy(
- maxResidentSegments: 8,
- maxChaptersPerSegment: 256,
- memoryBudgetBytes: 8 * 1024 * 1024
- )
-}
-
-final class RDEPUBBackgroundCoverageStore {
-
- private unowned let context: RDEPUBReaderContext
-
- private let policy: RDEPUBBackgroundCoverageStorePolicy
-
- private var segments: [RDEPUBBackgroundCoverageSegment] = []
-
- private var currentMemoryBytes: Int = 0
-
- private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
-
- init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
- self.context = context
- self.policy = policy
- }
-
- func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
-
- evictIfNeeded(forNewSegment: segment)
-
- var merged = false
- for (index, existing) in segments.enumerated() {
- if canMerge(existing, segment) {
- if let mergedSegment = mergeSegments(existing, segment) {
- segments[index] = mergedSegment
- currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes
- merged = true
- break
- }
- }
- }
-
- if !merged {
- segments.append(segment)
- currentMemoryBytes += segment.estimatedMemoryBytes
- }
-
- lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
- }
-
- func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
- let segment = segments.first { $0.contains(spineIndex: spineIndex) }
- if let segment {
- lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
- }
- return segment
- }
-
- func findSegment(covering spineIndices: Set) -> RDEPUBBackgroundCoverageSegment? {
- let segment = segments.first { segment in
- spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
- }
- if let segment {
- lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
- }
- return segment
- }
-
- func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
- segments
- }
-
- func clearAll() {
- segments.removeAll()
- currentMemoryBytes = 0
- lastAccessTime.removeAll()
- }
-
- func clearColdSegments(
- activeWindowSpineIndices: Set,
- protectedSpineIndices: Set
- ) {
- let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices)
- segments.removeAll { segment in
- let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) })
- if isCold {
- currentMemoryBytes -= segment.estimatedMemoryBytes
- lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
- }
- return isCold
- }
- }
-
- func handleMemoryWarning(
- activeWindowSpineIndices: Set,
- protectedSpineIndices: Set
- ) {
-
- clearColdSegments(
- activeWindowSpineIndices: activeWindowSpineIndices,
- protectedSpineIndices: protectedSpineIndices
- )
-
- if currentMemoryBytes > policy.memoryBudgetBytes {
-
- let sorted = segments.sorted { lhs, rhs in
- let lhsDistance = lhs.resolvedSpineIndices.map { idx in
- activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
- }.min() ?? Int.max
- let rhsDistance = rhs.resolvedSpineIndices.map { idx in
- activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
- }.min() ?? Int.max
- return lhsDistance > rhsDistance
- }
-
- for segment in sorted {
- if currentMemoryBytes <= policy.memoryBudgetBytes { break }
- currentMemoryBytes -= segment.estimatedMemoryBytes
- lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
- segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex }
- }
- }
- }
-
- private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
-
- while segments.count >= policy.maxResidentSegments {
- evictLeastRecentlyUsed()
- }
-
- while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
- evictLeastRecentlyUsed()
- }
- }
-
- private func evictLeastRecentlyUsed() {
- guard !segments.isEmpty else { return }
-
- var oldestTime = CFAbsoluteTimeGetCurrent()
- var oldestIndex = 0
- for (index, segment) in segments.enumerated() {
- let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0
- if accessTime < oldestTime {
- oldestTime = accessTime
- oldestIndex = index
- }
- }
-
- let evicted = segments.remove(at: oldestIndex)
- currentMemoryBytes -= evicted.estimatedMemoryBytes
- lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex)
- }
-
- private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
-
- guard lhs.renderSignature == rhs.renderSignature else { return false }
-
- let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
- rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
- return overlap
- }
-
- private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
- let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
- let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
- let newChapterCount = newUpper - newLower + 1
-
- if newChapterCount > policy.maxChaptersPerSegment {
-
- return nil
- }
-
- let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
-
- let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
- let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
- let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
-
- return RDEPUBBackgroundCoverageSegment(
- lowerSpineIndex: newLower,
- upperSpineIndex: newUpper,
- pageMap: newPageMap,
- resolvedSpineIndices: newResolved,
- generatedAt: max(lhs.generatedAt, rhs.generatedAt),
- renderSignature: lhs.renderSignature,
- estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count)
- )
- }
-
- private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
- var builder = RDEPUBBookPageMap.Builder()
- var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
-
- for entry in older.entries {
- entriesBySpineIndex[entry.spineIndex] = entry
- }
- for entry in newer.entries {
- entriesBySpineIndex[entry.spineIndex] = entry
- }
-
- for spineIndex in entriesBySpineIndex.keys.sorted() {
- guard let entry = entriesBySpineIndex[spineIndex] else { continue }
- builder.add(
- spineIndex: entry.spineIndex,
- href: entry.href,
- title: entry.title,
- pageCount: entry.pageCount,
- fragmentOffsets: entry.fragmentOffsets
- )
- }
-
- return builder.build()
- }
-
- private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
- 256 + pageMap.entries.count * 96 + resolvedCount * 16
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift
deleted file mode 100644
index 0d5a652..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift
+++ /dev/null
@@ -1,178 +0,0 @@
-import Foundation
-
-struct RDEPUBBackgroundPriorityPolicy {
-
- let hotRadius: Int
-
- let warmRadius: Int
-
- let maxWarmJumpAnchors: Int
-
- let coldLaneShare: Double
-
- static let `default` = RDEPUBBackgroundPriorityPolicy(
- hotRadius: 24,
- warmRadius: 96,
- maxWarmJumpAnchors: 2,
- coldLaneShare: 0.15
- )
-
- static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
- let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
- let warmRadius = min(max(hotRadius * 3, 32), 192)
- return RDEPUBBackgroundPriorityPolicy(
- hotRadius: hotRadius,
- warmRadius: warmRadius,
- maxWarmJumpAnchors: 2,
- coldLaneShare: 0.15
- )
- }
-}
-
-enum RDEPUBPriorityBand: Int, Comparable {
- case hot = 0
- case warmPrimary = 1
- case warmSecondary = 2
- case cold = 3
-
- static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool {
- lhs.rawValue < rhs.rawValue
- }
-}
-
-struct RDEPUBWarmJumpAnchor {
- let spineIndex: Int
- let timestamp: CFAbsoluteTime
- let sequenceNumber: Int
-}
-
-struct RDEPUBMetadataParseWorkItem {
- let spineIndex: Int
- let generation: Int
- let priorityBand: RDEPUBPriorityBand
-
- var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
- (priorityBand.rawValue, 0, 0, spineIndex)
- }
-}
-
-final class RDEPUBBackgroundPriorityManager {
-
- private unowned let context: RDEPUBReaderContext
-
- private(set) var policy: RDEPUBBackgroundPriorityPolicy
-
- private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
- private let warmAnchorsLock = NSLock()
-
- private(set) var currentGeneration: Int = 0
-
- private var coldCursor: Int = 0
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- self.policy = .default
- }
-
- func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
- policy = newPolicy
- }
-
- func addWarmAnchor(spineIndex: Int) {
- let anchor = RDEPUBWarmJumpAnchor(
- spineIndex: spineIndex,
- timestamp: CFAbsoluteTimeGetCurrent(),
- sequenceNumber: currentGeneration
- )
-
- warmAnchorsLock.lock()
- warmAnchors.insert(anchor, at: 0)
-
- if warmAnchors.count > policy.maxWarmJumpAnchors {
- warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
- }
- warmAnchorsLock.unlock()
-
- currentGeneration += 1
- coldCursor = 0
-
- }
-
- func makeMetadataPriorityOrder(
- allBuildableIndices: [Int],
- currentSpineIndex: Int?,
- cachedSpineIndices: Set
- ) -> [Int] {
- let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
- guard !uncachedIndices.isEmpty else { return [] }
-
- // M-03: Snapshot warmAnchors under lock since it's written on main thread
- // and read on background thread.
- warmAnchorsLock.lock()
- let warmAnchorsSnapshot = warmAnchors
- warmAnchorsLock.unlock()
-
- let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
- let band = classifySpineIndex(
- spineIndex: spineIndex,
- currentSpineIndex: currentSpineIndex,
- warmAnchors: warmAnchorsSnapshot
- )
- return (spineIndex, band)
- }
-
- let sorted = items.sorted { lhs, rhs in
-
- if lhs.band != rhs.band {
- return lhs.band < rhs.band
- }
-
- let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
- let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
- if lhsDistanceToCurrent != rhsDistanceToCurrent {
- return lhsDistanceToCurrent < rhsDistanceToCurrent
- }
-
- return lhs.spineIndex < rhs.spineIndex
- }
-
- return sorted.map { $0.spineIndex }
- }
-
- private func classifySpineIndex(
- spineIndex: Int,
- currentSpineIndex: Int?,
- warmAnchors: [RDEPUBWarmJumpAnchor]
- ) -> RDEPUBPriorityBand {
-
- if let current = currentSpineIndex {
- let distance = abs(spineIndex - current)
- if distance <= policy.hotRadius {
- return .hot
- }
- }
-
- for (index, anchor) in warmAnchors.enumerated() {
- let distance = abs(spineIndex - anchor.spineIndex)
- if distance <= policy.warmRadius {
- return index == 0 ? .warmPrimary : .warmSecondary
- }
- }
-
- return .cold
- }
-
- func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
- warmAnchorsLock.lock()
- defer { warmAnchorsLock.unlock() }
- return warmAnchors
- }
-
- func reset() {
- warmAnchorsLock.lock()
- warmAnchors.removeAll()
- warmAnchorsLock.unlock()
- currentGeneration = 0
- coldCursor = 0
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift
deleted file mode 100644
index 0c684dc..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift
+++ /dev/null
@@ -1,191 +0,0 @@
-import Foundation
-
-struct RDEPUBJumpSession {
-
- let anchorSpineIndex: Int
-
- let createdAt: CFAbsoluteTime
-
- let protectedSpineIndices: Set
-
- let sequenceNumber: Int
-
- let expiresAt: CFAbsoluteTime
-
- let reason: Reason
-
- enum Reason {
- case tableOfContentsJump
- case bookmarkJump
- case searchJump
- }
-
- enum EndReason {
-
- case coverageComplete
-
- case navigatedAway
-
- case timeout
-
- case superseded
- }
-}
-
-public struct RDEPUBJumpSessionPolicy: Equatable {
-
- public let exitPageThreshold: Int
-
- public let timeout: TimeInterval
-
- public let idleGracePeriod: TimeInterval
-
- public let protectedNeighborRadius: Int
-
- public static let `default` = RDEPUBJumpSessionPolicy(
- exitPageThreshold: 6,
- timeout: 20,
- idleGracePeriod: 1.5,
- protectedNeighborRadius: 1
- )
-
- public init(
- exitPageThreshold: Int = 6,
- timeout: TimeInterval = 20,
- idleGracePeriod: TimeInterval = 1.5,
- protectedNeighborRadius: Int = 1
- ) {
- self.exitPageThreshold = exitPageThreshold
- self.timeout = timeout
- self.idleGracePeriod = idleGracePeriod
- self.protectedNeighborRadius = protectedNeighborRadius
- }
-}
-
-final class RDEPUBJumpSessionManager {
-
- private unowned let context: RDEPUBReaderContext
-
- private(set) var activeSession: RDEPUBJumpSession?
-
- private var nextSequenceNumber: Int = 0
-
- private var consecutivePageCount: Int = 0
-
- private var lastPageDirection: PageDirection?
-
- private var lastActivityTime: CFAbsoluteTime = 0
-
- enum PageDirection {
- case forward
- case backward
- }
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- @discardableResult
- func createSession(
- anchorSpineIndex: Int,
- reason: RDEPUBJumpSession.Reason,
- totalSpineCount: Int
- ) -> RDEPUBJumpSession {
- let policy = context.configuration.jumpSessionPolicy
- let now = CFAbsoluteTimeGetCurrent()
-
- var protectedIndices: Set = [anchorSpineIndex]
- for offset in 1...policy.protectedNeighborRadius {
- let lower = anchorSpineIndex - offset
- let upper = anchorSpineIndex + offset
- if lower >= 0 {
- protectedIndices.insert(lower)
- }
- if upper < totalSpineCount {
- protectedIndices.insert(upper)
- }
- }
-
- nextSequenceNumber += 1
- let session = RDEPUBJumpSession(
- anchorSpineIndex: anchorSpineIndex,
- createdAt: now,
- protectedSpineIndices: protectedIndices,
- sequenceNumber: nextSequenceNumber,
- expiresAt: now + policy.timeout,
- reason: reason
- )
-
- activeSession = session
- consecutivePageCount = 0
- lastPageDirection = nil
- lastActivityTime = now
-
-
- return session
- }
-
- func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
- guard activeSession != nil else { return }
-
- let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward
- lastActivityTime = CFAbsoluteTimeGetCurrent()
-
- if direction == lastPageDirection {
- consecutivePageCount += 1
- } else {
- consecutivePageCount = 1
- lastPageDirection = direction
- }
- }
-
- func shouldAllowPageMapTakeover(candidateSpineIndices: Set) -> Bool {
- guard let session = activeSession else {
- return true
- }
-
- let protectedIndices = session.protectedSpineIndices
- let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
- Double(protectedIndices.count)
-
- return coverageRatio >= 0.8
- }
-
- func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
- guard let session = activeSession else { return nil }
-
- let now = CFAbsoluteTimeGetCurrent()
- let policy = context.configuration.jumpSessionPolicy
-
- if now >= session.expiresAt {
- if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
- return .timeout
- }
- }
-
- if !session.protectedSpineIndices.contains(currentSpineIndex) {
- if consecutivePageCount >= policy.exitPageThreshold {
- return .navigatedAway
- }
- } else {
-
- consecutivePageCount = 0
- }
-
- return nil
- }
-
- func endSession(_ reason: RDEPUBJumpSession.EndReason) {
- guard let session = activeSession else { return }
- activeSession = nil
- consecutivePageCount = 0
- lastPageDirection = nil
- }
-
- func clearSession() {
- activeSession = nil
- consecutivePageCount = 0
- lastPageDirection = nil
- nextSequenceNumber = 0
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift
deleted file mode 100644
index 40067fa..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import Foundation
-
-/// Memory footprint probe for the long-chapter optimization work
-/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the
-/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter
-/// load, every 20 page turns, settings invalidation, and rotation.
-enum RDEPUBMemoryProbe {
-
- static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe")
-
- private static let pageTurnLogStride = 20
-
- /// Main-thread only (page turns are delivered on main).
- private static var pageTurnCount = 0
-
- static func logPageTurn() {
- guard isEnabled else { return }
- pageTurnCount += 1
- guard pageTurnCount % pageTurnLogStride == 0 else { return }
- log("pageTurn count=\(pageTurnCount)")
- }
-
- static func log(_ event: String) {
- guard isEnabled else { return }
- print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB))
- }
-
- /// Current phys_footprint in MB. Cheap enough (one task_info call) to
- /// surface in the demo state snapshot for automated memory assertions.
- static var footprintMB: Double {
- Double(currentFootprint()) / 1_048_576
- }
-
- /// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
- private static func currentFootprint() -> UInt64 {
- var info = task_vm_info_data_t()
- var count = mach_msg_type_number_t(
- MemoryLayout.size / MemoryLayout.size
- )
- let result = withUnsafeMutablePointer(to: &info) { pointer in
- pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
- task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
- }
- }
- guard result == KERN_SUCCESS else { return 0 }
- return info.phys_footprint
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift
deleted file mode 100644
index 6408b3d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-import Foundation
-
-final class RDEPUBMetadataParseCancellationController {
-
- let token: UUID
-
- private let lock = NSLock()
-
- private weak var queue: OperationQueue?
-
- private var cancelled = false
-
- init(token: UUID) {
- self.token = token
- }
-
- func attach(queue: OperationQueue) {
- let shouldCancelImmediately: Bool
- lock.lock()
- self.queue = queue
- shouldCancelImmediately = cancelled
- lock.unlock()
-
- if shouldCancelImmediately {
- queue.cancelAllOperations()
- }
- }
-
- func cancel() {
- let queueToCancel: OperationQueue?
- lock.lock()
- cancelled = true
- queueToCancel = queue
- lock.unlock()
- queueToCancel?.cancelAllOperations()
- }
-
- var isCancelled: Bool {
- lock.lock()
- let value = cancelled
- lock.unlock()
- return value
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift
deleted file mode 100644
index d1bf3e5..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift
+++ /dev/null
@@ -1,604 +0,0 @@
-import Foundation
-
-final class RDEPUBMetadataParseWorker {
-
- private static let maxRetryCount = 3
- private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
-
- private final class ParseState {
-
- var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
-
- var totalResolvedCount: Int
-
- var lastAppliedCount: Int
-
- init(
- summariesBySpineIndex: [Int: RDEPUBChapterSummary],
- totalResolvedCount: Int,
- lastAppliedCount: Int
- ) {
- self.summariesBySpineIndex = summariesBySpineIndex
- self.totalResolvedCount = totalResolvedCount
- self.lastAppliedCount = lastAppliedCount
- }
- }
-
- private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
-
- weak var context: RDEPUBReaderContext?
-
- let cancellationController: RDEPUBMetadataParseCancellationController
-
- let pageMapRefreshInterval: Int
-
- private let token: UUID
-
- private let parser: RDEPUBParser
-
- private let publication: RDEPUBPublication
-
- private let pageSize: CGSize
-
- private let layoutConfig: RDEPUBTextLayoutConfig
-
- private let style: RDEPUBTextRenderStyle
-
- private let renderSignature: String
-
- private let allBuildableIndices: [Int]
-
- private let summaryDiskCache: RDEPUBChapterSummaryDiskCache?
-
- private let workerCount: Int
-
- private let contentHashBySpineIndex: [Int: String]
-
- private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
-
- private let progressLogStride = 4
-
- init(
- context: RDEPUBReaderContext,
- cancellationController: RDEPUBMetadataParseCancellationController,
- token: UUID,
- parser: RDEPUBParser,
- publication: RDEPUBPublication
- ) {
- self.context = context
- self.cancellationController = cancellationController
- self.token = token
- self.parser = parser
- self.publication = publication
-
- let pageSize = context.currentTextPageSize()
- self.pageSize = pageSize
- self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
- self.style = context.currentTextRenderStyle()
- self.renderSignature = context.currentRenderSignature()
- self.allBuildableIndices = publication.spine.indices.filter { index in
- guard publication.spine.indices.contains(index) else { return false }
- let item = publication.spine[index]
- return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
- }
- self.summaryDiskCache = context.runtime?.summaryDiskCache
- self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
- self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
-
- var hashes: [Int: String] = [:]
- for spineIndex in allBuildableIndices {
- guard let href = publication.spine.indices.contains(spineIndex)
- ? publication.spine[spineIndex].href : nil,
- let html = parser.htmlString(forRelativePath: href) else {
- hashes[spineIndex] = ""
- continue
- }
- hashes[spineIndex] = html.rd_sha256Hex
- }
- self.contentHashBySpineIndex = hashes
-
- let ctx = context
- let spine = publication.spine
- let sig = renderSignature
- self.catalog = allBuildableIndices.map { spineIndex in
- let item = spine[spineIndex]
- return (
- key: ctx.chapterCacheKey(
- forSpineIndex: spineIndex,
- precomputedContentHash: hashes[spineIndex] ?? "",
- renderSignature: sig
- ),
- spineIndex: spineIndex,
- href: item.href,
- title: item.title
- )
- }
- }
-
- func start(token: UUID, restoreLocation: RDEPUBLocation?) {
- let cancellationController = self.cancellationController
-
- DispatchQueue.global(qos: .utility).async { [self] in
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "workerDispatched token=\(token.uuidString)"
- )
- let context = self.context
- guard let context,
- context.controller != nil,
- !cancellationController.isCancelled,
- context.paginationToken == token else {
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
- )
- return
- }
- defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
- guard context.controller != nil,
- !cancellationController.isCancelled,
- context.paginationToken == token else {
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
- )
- return
- }
-
- if let restoredPageMap = self.restoreBookPageMapIfPossible() {
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
- )
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil,
- !cancellationController.isCancelled else { return }
- context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
- }
- return
- }
-
- let prewarmStart = CFAbsoluteTimeGetCurrent()
- let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
-
- let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
- let cachedSummaries = restored?.summaries ?? [:]
- let cachedSpineIndices = Set(cachedSummaries.keys)
- let resultLock = NSLock()
- let parseState = ParseState(
- summariesBySpineIndex: cachedSummaries,
- totalResolvedCount: cachedSpineIndices.count,
- lastAppliedCount: cachedSpineIndices.count
- )
-
- if !cachedSpineIndices.isEmpty {
- let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil else { return }
- context.runtime?.refreshBookPageMapInPlace(cachedMap)
- }
- }
-
- let prioritizedSpineIndices: [Int]
- if let priorityManager = context.runtime?.backgroundPriorityManager {
- let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
- .flatMap { context.normalizedSpineIndex(for: $0) }
- prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
- allBuildableIndices: self.allBuildableIndices,
- currentSpineIndex: currentSpineIndex,
- cachedSpineIndices: cachedSpineIndices
- )
- } else {
- prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
- }
-
- let uncachedSpineIndices = prioritizedSpineIndices
-
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
- )
- self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
- guard !cancellationController.isCancelled,
- context.controller != nil,
- context.paginationToken == token else {
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "workerAbortedDuringCooldown"
- )
- return
- }
-
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
- )
-
- let wallClockStart = CFAbsoluteTimeGetCurrent()
- var totalRenderMs: Double = 0
- var totalWriteMs: Double = 0
- var totalMergeMs: Double = 0
- var completedChapters = 0
- var failedChapters = 0
- let timingLock = NSLock()
-
- let queue = OperationQueue()
- queue.name = "com.RDEpubReader.metadata.parse"
- queue.qualityOfService = .utility
- queue.maxConcurrentOperationCount = self.workerCount
- cancellationController.attach(queue: queue)
-
- let refreshInterval = self.pageMapRefreshInterval
-
- for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
- let operation = BlockOperation()
- operation.addExecutionBlock { [weak operation] in
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled,
- operation?.isCancelled != true else {
- return
- }
- do {
-
- let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled,
- operation?.isCancelled != true else {
- return nil
- }
- let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
- let renderStart = CFAbsoluteTimeGetCurrent()
- guard let result = try chapterBuilder.buildChapter(
- parser: self.parser,
- publication: self.publication,
- spineIndex: spineIndex,
- pageSize: self.pageSize,
- style: self.style
- ) else {
- return nil
- }
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled,
- operation?.isCancelled != true else {
- return nil
- }
- let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
-
- let chapter = result.chapter
- let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
- let cacheKey = context.chapterCacheKey(
- forSpineIndex: spineIndex,
- precomputedContentHash: precomputedHash,
- renderSignature: self.renderSignature
- )
- let summary = RDEPUBChapterSummary(
- pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
- pageCount: chapter.pages.count,
- fragmentOffsets: chapter.fragmentOffsets,
- cfiMap: chapter.cfiMap,
- renderSignature: cacheKey.renderSignature,
- schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
- chapterContentHash: cacheKey.chapterContentHash,
- pageMetadataList: chapter.pages.map { .from($0.metadata) }
- )
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled,
- operation?.isCancelled != true else {
- return nil
- }
- let writeStart = CFAbsoluteTimeGetCurrent()
- self.summaryDiskCache?.write(summary: summary, for: cacheKey)
- let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
-
- timingLock.lock()
- totalRenderMs += renderElapsed
- totalWriteMs += writeElapsed
- completedChapters += 1
- timingLock.unlock()
-
- return summary
- }
-
- guard let renderResult else { return }
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled,
- operation?.isCancelled != true else {
- return
- }
-
- var snapshot: [Int: RDEPUBChapterSummary]?
- resultLock.lock()
- parseState.summariesBySpineIndex[spineIndex] = renderResult
- parseState.totalResolvedCount += 1
- let resolvedCount = parseState.totalResolvedCount
- let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
- || resolvedCount == cachedSpineIndices.count + 1
- || resolvedCount % self.progressLogStride == 0
- if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
- || parseState.totalResolvedCount == self.allBuildableIndices.count {
- parseState.lastAppliedCount = parseState.totalResolvedCount
- snapshot = parseState.summariesBySpineIndex
- }
- resultLock.unlock()
-
- if shouldLogProgress {
- let progressPercent = Self.progressPercent(
- resolved: resolvedCount,
- total: self.allBuildableIndices.count
- )
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
- )
- }
-
- if let snapshot {
- let mergeStart = CFAbsoluteTimeGetCurrent()
- let partialMap = self.buildPageMap(summaries: snapshot)
- let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
- timingLock.lock()
- totalMergeMs += mergeElapsed
- timingLock.unlock()
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
- )
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil,
- !cancellationController.isCancelled else { return }
- context.runtime?.refreshBookPageMapInPlace(partialMap)
- }
- }
- } catch {
- guard !cancellationController.isCancelled,
- context.paginationToken == token,
- context.controller != nil,
- operation?.isCancelled != true else {
- return
- }
- timingLock.lock()
- failedChapters += 1
- timingLock.unlock()
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
- )
-
- self.scheduleRetry(
- spineIndex: spineIndex,
- retryCount: 0,
- resultLock: resultLock,
- parseState: parseState,
- cancellationController: cancellationController
- )
- }
- }
- queue.addOperation(operation)
- }
- queue.waitUntilAllOperationsAreFinished()
- if !cancellationController.isCancelled,
- context.paginationToken == token,
- context.controller != nil {
- self.summaryDiskCache?.flushPendingWrites()
- }
-
- let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
- timingLock.lock()
- let renderTotal = Int(totalRenderMs)
- let writeTotal = Int(totalWriteMs)
- let mergeTotal = Int(totalMergeMs)
- let rendered = completedChapters
- let failed = failedChapters
- timingLock.unlock()
- let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
- context.lastMetadataParseWallClockMs = wallClockMs
- context.lastMetadataParseConcurrency = self.workerCount
-
- guard context.controller != nil,
- context.paginationToken == token,
- !cancellationController.isCancelled else {
- return
- }
-
- let finalMergeStart = CFAbsoluteTimeGetCurrent()
- let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
- let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
- )
-
- if let coverageStore = context.runtime?.backgroundCoverageStore {
- let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
- let lowerSpine = resolvedSpineIndices.min() ?? 0
- let upperSpine = resolvedSpineIndices.max() ?? 0
- let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
-
- let segment = RDEPUBBackgroundCoverageSegment(
- lowerSpineIndex: lowerSpine,
- upperSpineIndex: upperSpine,
- pageMap: pageMap,
- resolvedSpineIndices: resolvedSpineIndices,
- generatedAt: CFAbsoluteTimeGetCurrent(),
- renderSignature: self.renderSignature,
- estimatedMemoryBytes: estimatedBytes
- )
- coverageStore.addSegment(segment)
- }
-
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil else { return }
- context.runtime?.refreshBookPageMapInPlace(pageMap)
- }
- }
- }
-
- // MARK: - Private
-
- private func waitForReadingInteractionToSettle(
- cancellationController: RDEPUBMetadataParseCancellationController? = nil
- ) {
- let semaphore = DispatchSemaphore(value: 0)
- let maxWaitIterations = 100 // ~8 seconds max wait
- var iteration = 0
- while context?.controller != nil,
- cancellationController?.isCancelled != true,
- iteration < maxWaitIterations {
- let elapsed = context?.secondsSinceLastUserNavigation() ?? 0
- if elapsed >= backgroundInteractionCooldown { break }
- semaphore.wait(timeout: .now() + 0.08)
- iteration += 1
- }
- }
-
- private func scheduleRetry(
- spineIndex: Int,
- retryCount: Int,
- resultLock: NSLock,
- parseState: ParseState,
- cancellationController: RDEPUBMetadataParseCancellationController
- ) {
- guard retryCount < Self.maxRetryCount else {
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
- )
- return
- }
-
- let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
- )
-
- DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
- guard let self, let context = self.context else { return }
- guard context.controller != nil,
- context.paginationToken == self.token,
- !cancellationController.isCancelled else {
- return
- }
-
- do {
- let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
- guard let result = try chapterBuilder.buildChapter(
- parser: self.parser,
- publication: self.publication,
- spineIndex: spineIndex,
- pageSize: self.pageSize,
- style: self.style
- ) else {
- return
- }
- guard context.controller != nil,
- context.paginationToken == self.token,
- !cancellationController.isCancelled else {
- return
- }
-
- let chapter = result.chapter
- let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
- let cacheKey = context.chapterCacheKey(
- forSpineIndex: spineIndex,
- precomputedContentHash: precomputedHash,
- renderSignature: self.renderSignature
- )
- let summary = RDEPUBChapterSummary(
- pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
- pageCount: chapter.pages.count,
- fragmentOffsets: chapter.fragmentOffsets,
- cfiMap: chapter.cfiMap,
- renderSignature: cacheKey.renderSignature,
- schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
- chapterContentHash: cacheKey.chapterContentHash,
- pageMetadataList: chapter.pages.map { .from($0.metadata) }
- )
- guard context.controller != nil,
- context.paginationToken == self.token,
- !cancellationController.isCancelled else {
- return
- }
- self.summaryDiskCache?.write(summary: summary, for: cacheKey)
-
- resultLock.lock()
- parseState.summariesBySpineIndex[spineIndex] = summary
- parseState.totalResolvedCount += 1
- let shouldRefresh =
- parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval
- || parseState.totalResolvedCount == self.allBuildableIndices.count
- if shouldRefresh {
- parseState.lastAppliedCount = parseState.totalResolvedCount
- }
- resultLock.unlock()
-
- if shouldRefresh {
- let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
- RDEPUBBackgroundTrace.log(
- "Metadata",
- "retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
- )
- DispatchQueue.main.async {
- guard context.paginationToken == self.token,
- context.controller != nil,
- !cancellationController.isCancelled else { return }
- context.runtime?.refreshBookPageMapInPlace(partialMap)
- }
- }
-
- } catch {
-
- self.scheduleRetry(
- spineIndex: spineIndex,
- retryCount: retryCount + 1,
- resultLock: resultLock,
- parseState: parseState,
- cancellationController: cancellationController
- )
- }
- }
- }
-
- private static func progressPercent(resolved: Int, total: Int) -> Int {
- guard total > 0 else { return 0 }
- return Int((Double(resolved) / Double(total) * 100.0).rounded())
- }
-
- private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
- guard let summaryDiskCache else { return nil }
- guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
- return nil
- }
- let restored = summaryDiskCache.readAll(keys: catalog)
- guard restored.summaries.count == catalog.count else {
- return nil
- }
- return restored.mapBuilder.build()
- }
-
- private func buildPageMap(
- summaries: [Int: RDEPUBChapterSummary]
- ) -> RDEPUBBookPageMap {
- var builder = RDEPUBBookPageMap.Builder()
- for item in catalog {
- guard let summary = summaries[item.spineIndex] else { continue }
- builder.add(
- spineIndex: item.spineIndex,
- href: item.href,
- title: item.title,
- pageCount: summary.pageCount,
- fragmentOffsets: summary.fragmentOffsets
- )
- }
- return builder.build()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift
deleted file mode 100644
index c601bfe..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift
+++ /dev/null
@@ -1,230 +0,0 @@
-import Foundation
-
-enum RDEPUBPageMapTakeoverDecision {
-
- case keepCurrentWindow
-
- case expandWindow(RDEPUBBackgroundCoverageSegment)
-
- case segmentReplace(RDEPUBBackgroundCoverageSegment)
-
- case fullReplace(RDEPUBBookPageMap)
-}
-
-final class RDEPUBPageMapReconciliationCoordinator {
-
- private unowned let context: RDEPUBReaderContext
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func evaluateTakeover(
- candidatePageMap: RDEPUBBookPageMap?,
- candidateSegment: RDEPUBBackgroundCoverageSegment?,
- currentWindow: RDEPUBBookPageMap?,
- jumpSession: RDEPUBJumpSession?
- ) -> RDEPUBPageMapTakeoverDecision {
-
- guard let currentWindow else {
- if let candidatePageMap {
- return .fullReplace(candidatePageMap)
- }
- return .keepCurrentWindow
- }
-
- let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
- .flatMap { context.normalizedSpineIndex(for: $0) }
-
- let lastBuildableSpineIndex = context.publication?.spine.indices
- .reversed()
- .first(where: { index in
- guard let item = context.publication?.spine[index] else { return false }
- return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
- }) ?? 0
-
- if let jumpSession {
- let protectedIndices = jumpSession.protectedSpineIndices
- if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
-
- if let candidateSegment {
- let candidateIndices = candidateSegment.resolvedSpineIndices
- let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
- Double(protectedIndices.count)
- if coverageRatio < 0.8 {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)"
- )
- return .keepCurrentWindow
- }
- }
- }
- }
-
- if let currentSpineIndex {
- let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
-
- if requiresAdjacentCoverage {
- if let candidateSegment {
- let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1)
- let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1)
- if !hasPrev || !hasNext {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
- )
- return .keepCurrentWindow
- }
- }
- }
- }
-
- if let candidateSegment {
- let currentRenderSignature = context.currentRenderSignature()
- if candidateSegment.renderSignature != currentRenderSignature {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)"
- )
- return .keepCurrentWindow
- }
- }
-
- if let candidateSegment {
- return evaluateSegmentTakeover(
- candidateSegment: candidateSegment,
- currentWindow: currentWindow,
- currentSpineIndex: currentSpineIndex,
- lastBuildableSpineIndex: lastBuildableSpineIndex
- )
- }
-
- if let candidatePageMap {
- return evaluateFullPageMapTakeover(
- candidatePageMap: candidatePageMap,
- currentWindow: currentWindow,
- currentSpineIndex: currentSpineIndex,
- lastBuildableSpineIndex: lastBuildableSpineIndex
- )
- }
-
- return .keepCurrentWindow
- }
-
- private func evaluateSegmentTakeover(
- candidateSegment: RDEPUBBackgroundCoverageSegment,
- currentWindow: RDEPUBBookPageMap,
- currentSpineIndex: Int?,
- lastBuildableSpineIndex: Int
- ) -> RDEPUBPageMapTakeoverDecision {
- let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
- let candidateIndices = candidateSegment.resolvedSpineIndices
-
- if let currentSpineIndex {
- if !candidateIndices.contains(currentSpineIndex) {
- return .keepCurrentWindow
- }
- }
-
- if let currentSpineIndex {
- let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
- let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
- currentSpineIndex == lastBuildableSpineIndex
- if !hasPrev || !hasNext {
- return .keepCurrentWindow
- }
- }
-
- let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
- currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
- candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
- candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
-
- if isContinuous {
-
- return .expandWindow(candidateSegment)
- } else {
-
- let overlap = currentIndices.intersection(candidateIndices)
- let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
- if overlapRatio > 0.5 {
-
- return .segmentReplace(candidateSegment)
- }
- }
-
- return .keepCurrentWindow
- }
-
- private func evaluateFullPageMapTakeover(
- candidatePageMap: RDEPUBBookPageMap,
- currentWindow: RDEPUBBookPageMap,
- currentSpineIndex: Int?,
- lastBuildableSpineIndex: Int
- ) -> RDEPUBPageMapTakeoverDecision {
- let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
- let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
- let currentEntries = currentWindow.entries.count
-
- if let currentSpineIndex {
- if !candidateIndices.contains(currentSpineIndex) {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)"
- )
- return .keepCurrentWindow
- }
- }
-
- if let currentSpineIndex {
- let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
- let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
- currentSpineIndex == lastBuildableSpineIndex
- if !hasPrev || !hasNext {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
- )
- return .keepCurrentWindow
- }
- }
-
- let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
- if coversCurrentWindow {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
- )
- return .fullReplace(candidatePageMap)
- }
-
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
- )
- return .keepCurrentWindow
- }
-
- func protectedSpineIndices(
- currentSpineIndex: Int?,
- jumpSession: RDEPUBJumpSession?
- ) -> Set {
- var indices: Set = []
-
- if let currentSpineIndex {
- indices.insert(currentSpineIndex)
-
- if currentSpineIndex > 0 {
- indices.insert(currentSpineIndex - 1)
- }
- indices.insert(currentSpineIndex + 1)
- }
-
- if let jumpSession {
- indices.formUnion(jumpSession.protectedSpineIndices)
- }
-
- return indices
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift
deleted file mode 100644
index 48d0bbe..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift
+++ /dev/null
@@ -1,471 +0,0 @@
-import UIKit
-
-enum RDEPUBPendingPageMapUpdateKind {
- case reconcileFullMap
- case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
- case appendForward
-}
-
-struct RDEPUBPendingPageMapUpdate {
- let pageMap: RDEPUBBookPageMap
- let kind: RDEPUBPendingPageMapUpdateKind
-}
-
-final class RDEPUBPresentationRuntime {
-
- private unowned let context: RDEPUBReaderContext
-
- private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
-
- private unowned let jumpSessionManager: RDEPUBJumpSessionManager
-
- private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
-
- private var pendingCommitRetryWorkItem: DispatchWorkItem?
-
- init(
- context: RDEPUBReaderContext,
- locationCoordinator: RDEPUBReaderLocationCoordinator,
- jumpSessionManager: RDEPUBJumpSessionManager,
- reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
- ) {
- self.context = context
- self.locationCoordinator = locationCoordinator
- self.jumpSessionManager = jumpSessionManager
- self.reconciliationCoordinator = reconciliationCoordinator
- }
-
- func applyBookPageMap(
- _ bookPageMap: RDEPUBBookPageMap,
- restoreLocation: RDEPUBLocation?,
- finishPagination: (RDEPUBLocation?) -> Void
- ) {
- context.textBook = nil
- context.bookPageMap = bookPageMap
- context.pendingPageMapUpdates.removeAll()
- context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
- finishPagination(restoreLocation)
- }
-
- func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
- )
- enqueuePendingPageMapUpdate(
- RDEPUBPendingPageMapUpdate(
- pageMap: bookPageMap,
- kind: .reconcileFullMap
- )
- )
- }
-
- func commitPendingPageMapUpdateIfNeeded() {
- guard let readerView = context.readerView,
- let controller = context.controller else { return }
-
- guard !context.pendingPageMapUpdates.isEmpty else {
- cancelPendingCommitRetry()
- return
- }
-
- guard !controller.isRepaginating else {
- schedulePendingCommitRetry()
- return
- }
- guard !readerView.isPageCurlTransitioning else {
- schedulePendingCommitRetry()
- return
- }
-
- cancelPendingCommitRetry()
-
- let rankedUpdates = rankedPendingPageMapUpdates()
- for (index, update) in rankedUpdates {
- if commitPendingPageMapUpdate(
- update,
- at: index,
- readerView: readerView,
- controller: controller
- ) {
- if !context.pendingPageMapUpdates.isEmpty {
- schedulePendingCommitRetry()
- }
- return
- }
- }
-
- if !context.pendingPageMapUpdates.isEmpty {
- schedulePendingCommitRetry()
- }
- }
-
- func queueExtendedPartialPageMap(
- _ bookPageMap: RDEPUBBookPageMap,
- currentPageNumber: Int,
- currentLocation: RDEPUBLocation?
- ) {
- enqueuePendingPageMapUpdate(
- RDEPUBPendingPageMapUpdate(
- pageMap: bookPageMap,
- kind: .extendPartial(
- currentPageNumber: currentPageNumber,
- currentLocation: currentLocation
- )
- )
- )
- }
-
- func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
- context.bookPageMap = bookPageMap
- context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
- }
-
- func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
- enqueuePendingPageMapUpdate(
- RDEPUBPendingPageMapUpdate(
- pageMap: bookPageMap,
- kind: .appendForward
- )
- )
- }
-
- func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
- let pages = bookPageMap.entries.flatMap { entry in
- (0.. full-book takeover).
- let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
-
- context.textBook = nil
- applyPageMapToLiveModel(newPageMap)
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
- )
-
- if let currentLocation {
- let resolvedTargetPage = controller.pageNumber(for: currentLocation)
- let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
- resolvedTargetPage,
- livePageBeforeApply: livePageBeforeApply,
- locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
- )
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
- )
-
- if shouldTrustResolvedLocation,
- rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
- return
- }
-
- let fallbackPage = max(livePageBeforeApply, 1)
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
- )
- rebindVisiblePage(
- to: fallbackPage - 1,
- readerView: readerView
- )
- } else {
- readerView.reloadPageCountOnly()
- }
-
- if let currentLocation,
- context.normalizedSpineIndex(for: currentLocation) != nil,
- let activeSession = jumpSessionManager.activeSession {
- let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
- let protectedIndices = activeSession.protectedSpineIndices
- if protectedIndices.isSubset(of: candidateIndices) {
- jumpSessionManager.endSession(.coverageComplete)
- }
- }
- }
-
- private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
- var updates = context.pendingPageMapUpdates
- if let existingIndex = updates.firstIndex(where: {
- pendingPageMapUpdateKindMatches($0.kind, update.kind)
- }) {
- let existing = updates[existingIndex]
- if shouldReplacePendingPageMapUpdate(existing, with: update) {
- updates[existingIndex] = update
- }
- } else {
- updates.append(update)
- }
- context.pendingPageMapUpdates = updates
- commitPendingPageMapUpdateIfNeeded()
- }
-
- private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
- context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
- pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
- }
- }
-
- private func commitPendingPageMapUpdate(
- _ update: RDEPUBPendingPageMapUpdate,
- at index: Int,
- readerView: RDEpubReaderView,
- controller: RDEPUBReaderController
- ) -> Bool {
- switch update.kind {
- case .reconcileFullMap:
- let decision = reconciliationCoordinator.evaluateTakeover(
- candidatePageMap: update.pageMap,
- candidateSegment: nil,
- currentWindow: context.bookPageMap,
- jumpSession: jumpSessionManager.activeSession
- )
-
- switch decision {
- case .keepCurrentWindow:
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)"
- )
- removePendingPageMapUpdate(at: index)
- return false
-
- case .fullReplace(let newPageMap):
- removePendingPageMapUpdate(at: index)
- applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
- return true
-
- case .expandWindow, .segmentReplace:
- removePendingPageMapUpdate(at: index)
- return false
- }
-
- case .extendPartial(let capturedPageNumber, let currentLocation):
- removePendingPageMapUpdate(at: index)
- applyPageMapToLiveModel(update.pageMap)
- let livePageNumber = max(readerView.currentPage, 0) + 1
- let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
- )
- if shouldTrustCapturedLocation,
- let currentLocation,
- rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
- return true
- }
- // Prefer the live readerView page when the user has moved since the
- // extension request was created; otherwise a stale captured location can
- // snap pageCurl back to the previous page after the turn completes.
- let livePageIndex = max(readerView.currentPage, 0)
- rebindVisiblePage(
- to: livePageIndex,
- readerView: readerView
- )
- return true
-
- case .appendForward:
- removePendingPageMapUpdate(at: index)
- applyPageMapToLiveModel(update.pageMap)
- readerView.reloadPageCountOnly()
- return true
- }
- }
-
- private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) {
- if pageIndex == readerView.currentPage {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
- )
- readerView.reloadPageCountOnly()
- return
- }
-
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
- )
- if readerView.currentDisplayType == .pageCurl {
- if readerView.isPageCurlTransitioning {
- // Defer the transition until the current page-curl animation completes,
- // and re-read the live page at that time to avoid jumping to a stale position.
- DispatchQueue.main.async { [weak readerView] in
- guard let readerView, !readerView.isPageCurlTransitioning else { return }
- let livePageIndex = max(readerView.currentPage, 0)
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
- )
- readerView.transitionToPage(pageNum: livePageIndex, animated: false)
- }
- } else {
- readerView.transitionToPage(pageNum: pageIndex, animated: false)
- }
- } else {
- readerView.reloadPageCountOnly()
- if pageIndex != readerView.currentPage {
- readerView.transitionToPage(pageNum: pageIndex, animated: false)
- }
- }
- }
-
- private func rebindVisibleLocation(
- _ location: RDEPUBLocation,
- readerView: RDEpubReaderView,
- controller: RDEPUBReaderController
- ) -> Bool {
- guard let targetPageNumber = controller.pageNumber(for: location) else {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
- )
- return false
- }
-
- if context.bookPageMap != nil,
- context.runtime?.prepareOnDemandChapter(
- forAbsolutePageNumber: targetPageNumber,
- allowSynchronousLoad: false
- ) == false {
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
- )
- return false
- }
-
- RDEPUBBackgroundTrace.log(
- "Reconciliation",
- "rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
- )
-
- rebindVisiblePage(
- to: max(targetPageNumber - 1, 0),
- readerView: readerView
- )
- return true
- }
-
- private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
- context.bookPageMap = pageMap
- context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
- discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
- }
-
- private func removePendingPageMapUpdate(at index: Int) {
- var updates = context.pendingPageMapUpdates
- guard updates.indices.contains(index) else { return }
- updates.remove(at: index)
- context.pendingPageMapUpdates = updates
- }
-
- private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
- let updates = context.pendingPageMapUpdates.filter { update in
- update.pageMap.totalChapters > liveMap.totalChapters
- || (
- update.pageMap.totalChapters == liveMap.totalChapters
- && update.pageMap.totalPages > liveMap.totalPages
- )
- }
- context.pendingPageMapUpdates = updates
- }
-
- private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
- switch kind {
- case .extendPartial:
- return 3
- case .appendForward:
- return 2
- case .reconcileFullMap:
- return 1
- }
- }
-
- private func pendingPageMapUpdateKindMatches(
- _ lhs: RDEPUBPendingPageMapUpdateKind,
- _ rhs: RDEPUBPendingPageMapUpdateKind
- ) -> Bool {
- switch (lhs, rhs) {
- case (.reconcileFullMap, .reconcileFullMap),
- (.appendForward, .appendForward),
- (.extendPartial, .extendPartial):
- return true
- default:
- return false
- }
- }
-
- private func shouldReplacePendingPageMapUpdate(
- _ existing: RDEPUBPendingPageMapUpdate,
- with candidate: RDEPUBPendingPageMapUpdate
- ) -> Bool {
- candidate.pageMap.totalChapters > existing.pageMap.totalChapters
- || (
- candidate.pageMap.totalChapters == existing.pageMap.totalChapters
- && candidate.pageMap.totalPages >= existing.pageMap.totalPages
- )
- }
-
- private func shouldTrustFullReplaceResolvedPage(
- _ resolvedTargetPage: Int?,
- livePageBeforeApply: Int,
- locationMatchesLivePage: Bool
- ) -> Bool {
- guard let resolvedTargetPage else { return false }
- if locationMatchesLivePage {
- return true
- }
- // The location did not round-trip to the live page in the outgoing map
- // (stale persisted location or mid-transition), so only follow it when it
- // stays next to the page the user is actually looking at.
- return abs(resolvedTargetPage - livePageBeforeApply) <= 1
- }
-
- private func schedulePendingCommitRetry() {
- guard pendingCommitRetryWorkItem == nil else { return }
-
- let workItem = DispatchWorkItem { [weak self] in
- guard let self else { return }
- self.pendingCommitRetryWorkItem = nil
- self.commitPendingPageMapUpdateIfNeeded()
- }
- pendingCommitRetryWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
- }
-
- private func cancelPendingCommitRetry() {
- pendingCommitRetryWorkItem?.cancel()
- pendingCommitRetryWorkItem = nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift
deleted file mode 100644
index 8354f60..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift
+++ /dev/null
@@ -1,578 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderAnnotationCoordinator {
-
- private unowned let context: RDEPUBReaderContext
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- private var controller: RDEPUBReaderController? {
- context.controller
- }
-
- func bookmark(withID id: String) -> RDEPUBBookmark? {
- guard let controller else { return nil }
- return controller.activeBookmarks.first { $0.id == id }
- }
-
- func highlight(withID id: String) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- return controller.activeHighlights.first { $0.id == id }
- }
-
- func updateCurrentSelection(_ selection: RDEPUBSelection?) {
- if let selection, !selection.isEmpty {
- applySelectionState(.selected(selection))
- } else {
- applySelectionState(.idle)
- }
- }
-
- func applySelectionState(_ state: RDEPUBSelectionState) {
- guard let controller else { return }
- context.selectionState = state
- switch state {
- case .idle:
- controller.updateReaderChrome()
- controller.delegate?.epubReader(controller, didChangeSelection: nil)
- case .selecting:
- break
- case .selected(let selection):
- controller.updateReaderChrome()
- controller.delegate?.epubReader(controller, didChangeSelection: selection)
- case .committingAction:
- break
- }
- }
-
- @discardableResult
- func addHighlight(
- from selection: RDEPUBSelection? = nil,
- color: String = "#F8E16C",
- note: String? = nil
- ) -> RDEPUBHighlight? {
- addAnnotation(from: selection, style: .highlight, color: color, note: note)
- }
-
- @discardableResult
- func addAnnotation(
- from selection: RDEPUBSelection? = nil,
- style: RDEPUBHighlightStyle,
- color: String = "#F8E16C",
- note: String? = nil
- ) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- let sourceSelection = selection ?? controller.currentSelection
- guard let sourceSelection,
- let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
- !scopedSelection.isEmpty else {
- return nil
- }
-
- let newHighlight = RDEPUBHighlight(
- bookIdentifier: controller.currentBookIdentifier,
- location: scopedSelection.location,
- text: scopedSelection.text,
- rangeInfo: scopedSelection.rangeInfo,
- style: style,
- color: color,
- note: note
- )
-
- let isDuplicate = controller.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
- }
-
- controller.activeHighlights.append(newHighlight)
- persistHighlightsAndRefreshContent()
- updateCurrentSelection(nil)
- return newHighlight
- }
-
- @discardableResult
- func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- guard let scopedHighlight = scopedHighlight(highlight) else {
- return nil
- }
-
- if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
- controller.activeHighlights[index] = scopedHighlight
- } else {
- controller.activeHighlights.append(scopedHighlight)
- }
- persistHighlightsAndRefreshContent()
- return scopedHighlight
- }
-
- @discardableResult
- func removeHighlight(id: String) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
- return nil
- }
- let removed = controller.activeHighlights.remove(at: index)
- persistHighlightsAndRefreshContent()
- return removed
- }
-
- @discardableResult
- func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
- return nil
- }
- controller.activeHighlights[index].note = normalizedNote(note)
- persistHighlightsAndRefreshContent()
- return controller.activeHighlights[index]
- }
-
- @discardableResult
- func go(toHighlightID id: String, animated: Bool = true) -> Bool {
- guard let highlight = highlight(withID: id) else {
- return false
- }
- return navigate(to: highlight, animated: animated)
- }
-
- func removeAllHighlights() {
- guard let controller else { return }
- guard !controller.activeHighlights.isEmpty else { return }
- controller.activeHighlights.removeAll()
- persistHighlightsAndRefreshContent()
- }
-
- func scopedSelection(
- _ selection: RDEPUBSelection,
- relativeToSpineIndex spineIndex: Int?
- ) -> RDEPUBSelection? {
- guard let controller else { return nil }
- guard let publication = controller.publication else { return nil }
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- selection.location,
- relativeToSpineIndex: spineIndex,
- bookIdentifier: controller.currentBookIdentifier
- ) ?? RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: selection.location.href,
- progression: selection.location.progression,
- lastProgression: selection.location.lastProgression,
- fragment: selection.location.fragment,
- rangeAnchor: selection.location.rangeAnchor,
- cfi: selection.location.cfi,
- lastCFI: selection.location.lastCFI,
- rangeCFI: selection.location.rangeCFI
- )
- return RDEPUBSelection(
- bookIdentifier: controller.currentBookIdentifier,
- location: normalizedLocation,
- text: selection.text,
- rangeInfo: selection.rangeInfo,
- createdAt: selection.createdAt
- )
- }
-
- func presentHighlightsManager() {
- guard let controller else { return }
- guard controller.configuration.allowsHighlights else { return }
- guard !controller.activeHighlights.isEmpty else { return }
-
- let highlightsController = RDEPUBReaderHighlightsViewController(
- highlights: controller.activeHighlights,
- theme: controller.configuration.theme,
- sectionTitleProvider: { [weak self] highlight in
- self?.titleForHighlight(highlight)
- }
- )
- highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
- highlightsController?.dismiss(animated: true) {
- _ = self?.navigate(to: highlight, animated: true)
- }
- }
- highlightsController.onUpdateHighlight = { [weak self] highlight in
- _ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
- }
- highlightsController.onDeleteHighlight = { [weak self] highlight in
- _ = self?.controller?.removeHighlight(id: highlight.id)
- }
-
- let navigationController = UINavigationController(rootViewController: highlightsController)
- navigationController.modalPresentationStyle = .pageSheet
- controller.present(navigationController, animated: true)
- }
-
- func presentAnnotationCreation() {
- guard let controller else { return }
- guard controller.configuration.allowsHighlights,
- let currentSelection = controller.currentSelection else {
- return
- }
- presentAnnotationActionSheet(for: currentSelection)
- }
-
- func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
- guard let controller else { return }
- let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
- alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
- _ = self?.removeHighlight(id: highlight.id)
- })
- if highlight.hasNote {
- alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
- _ = self?.updateHighlightNote(id: highlight.id, note: nil)
- })
- }
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
-
- if let popover = alert.popoverPresentationController {
- popover.sourceView = sourceView
- popover.sourceRect = sourceRect
- }
-
- controller.present(alert, animated: true)
- }
-
- 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)
- }
- }
-
- @discardableResult
- func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
- guard let controller else { return nil }
- guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
- return nil
- }
- guard bookmark(matching: location) == nil else {
- return nil
- }
-
- let newBookmark = RDEPUBBookmark(
- bookIdentifier: controller.currentBookIdentifier,
- location: location,
- rangeInfo: controller.currentVisibleRangeInfo(),
- chapterTitle: titleForBookmarkLocation(location),
- note: normalizedBookmarkNote(note)
- )
- controller.activeBookmarks.append(newBookmark)
- persistBookmarksAndRefreshChrome()
- return newBookmark
- }
-
- @discardableResult
- func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
- guard let controller else { return nil }
- guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
- return nil
- }
-
- if let existingBookmark = bookmark(matching: location) {
- _ = removeBookmark(id: existingBookmark.id)
- return nil
- }
-
- return addBookmark(note: note)
- }
-
- @discardableResult
- func removeBookmark(id: String) -> RDEPUBBookmark? {
- guard let controller else { return nil }
- guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
- return nil
- }
- let removed = controller.activeBookmarks.remove(at: index)
- persistBookmarksAndRefreshChrome()
- return removed
- }
-
- @discardableResult
- func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
- guard let controller else { return false }
- guard let bookmark = bookmark(withID: id) else {
- return false
- }
- return controller.restoreReadingLocation(
- bookmark.location,
- animated: animated,
- targetHighlightRangeInfo: bookmark.rangeInfo
- )
- }
-
- func presentBookmarksManager() {
- guard let controller else { return }
- guard !controller.activeBookmarks.isEmpty else { return }
-
- let bookmarksController = RDEPUBReaderBookmarksViewController(
- bookmarks: controller.activeBookmarks,
- theme: controller.configuration.theme
- )
- bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
- guard let controller = self?.controller else { return }
- bookmarksController?.dismiss(animated: true) {
- _ = controller.restoreReadingLocation(
- bookmark.location,
- animated: true,
- targetHighlightRangeInfo: bookmark.rangeInfo
- )
- }
- }
- bookmarksController.onDeleteBookmark = { [weak self] bookmark in
- _ = self?.controller?.removeBookmark(id: bookmark.id)
- }
-
- let navigationController = UINavigationController(rootViewController: bookmarksController)
- navigationController.modalPresentationStyle = .pageSheet
- controller.present(navigationController, animated: true)
- }
-
- private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
- guard let controller else { return nil }
- guard let publication = controller.publication else { return nil }
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- highlight.location,
- relativeToSpineIndex: nil,
- bookIdentifier: controller.currentBookIdentifier
- ) ?? RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: highlight.location.href,
- progression: highlight.location.progression,
- lastProgression: highlight.location.lastProgression,
- fragment: highlight.location.fragment,
- rangeAnchor: highlight.location.rangeAnchor,
- cfi: highlight.location.cfi,
- lastCFI: highlight.location.lastCFI,
- rangeCFI: highlight.location.rangeCFI
- )
- return RDEPUBHighlight(
- id: highlight.id,
- bookIdentifier: controller.currentBookIdentifier,
- location: normalizedLocation,
- text: highlight.text,
- rangeInfo: highlight.rangeInfo,
- style: highlight.style,
- color: highlight.color,
- note: highlight.note,
- createdAt: highlight.createdAt
- )
- }
-
- @discardableResult
- private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
- guard let controller else { return false }
- let navigationTarget = scopedHighlight(highlight) ?? highlight
- return controller.restoreReadingLocation(
- navigationTarget.location,
- animated: animated,
- targetHighlightRangeInfo: navigationTarget.rangeInfo
- )
- }
-
- private func persistHighlightsAndRefreshContent() {
- guard let controller else { return }
- if let currentBookIdentifier = controller.currentBookIdentifier {
- controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
- }
- controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
- controller.updateReaderChrome()
- refreshVisibleContentPreservingCurrentPage()
- }
-
- private func refreshVisibleContentPreservingCurrentPage() {
- guard let controller else { return }
- let currentPage = controller.readerView.currentPage
- guard currentPage >= 0 else {
- controller.refreshVisibleContentPreservingLocation()
- return
- }
-
- controller.readerView.reloadData()
- if controller.readerView.currentPage != currentPage {
- controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
- }
- }
-
- private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
- guard let controller else { return }
- 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 = controller.bottomToolView
- popover.sourceRect = controller.bottomToolView.bounds
- }
-
- controller.present(alert, animated: true)
- }
-
- private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
- _ = addAnnotation(from: selection, style: style, note: note)
- }
-
- private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
- guard let controller else { return }
- 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
- )
- })
- controller.present(alert, animated: true)
- }
-
- private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
- guard let controller else { return nil }
- guard let publication = controller.publication,
- let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
- return nil
- }
-
- return controller.flattenedTableOfContents.first { item in
- let rawHref = item.href.components(separatedBy: "#").first ?? item.href
- return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
- }?.title
- }
-
- private func normalizedNote(_ note: String?) -> String? {
- let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? nil : trimmed
- }
-
- private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
- guard let controller else { return nil }
- if let currentLocation = controller.currentVisibleLocation(),
- bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
- return controller.currentTableOfContentsItem?.title
- }
-
- return controller.flattenedTableOfContents.last { item in
- bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
- }?.title
- }
-
- private func persistBookmarksAndRefreshChrome() {
- guard let controller else { return }
- guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
- controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
- controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
- controller.updateReaderChrome()
- }
-
- func currentBookmark() -> RDEPUBBookmark? {
- guard let controller else { return nil }
- guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
- return nil
- }
- return bookmark(matching: location)
- }
-
- private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
- guard let controller else { return nil }
- guard let location else { return nil }
- return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
- }
-
- private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
- guard let controller else { return false }
- guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
- return false
- }
-
- if let bookmarkCFI = bookmark.location.cfi,
- let locationCFI = location.cfi {
- return bookmarkCFI == locationCFI
- }
-
- 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 = controller.publication?.layout == .fixed ? 0.01 : 0.05
- return progressionDelta <= threshold
- }
-
- private func bookmarkHref(for location: RDEPUBLocation) -> String {
- controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
- }
-
- private func bookmarkHref(forTableOfContentsHref href: String) -> String {
- let rawHref = href.components(separatedBy: "#").first ?? href
- return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
- }
-
- private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
- guard let controller else { return nil }
- guard let location else { return nil }
- guard let publication = controller.publication else {
- return RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: location.href,
- progression: location.progression,
- lastProgression: location.lastProgression,
- fragment: location.fragment,
- rangeAnchor: location.rangeAnchor,
- cfi: location.cfi,
- lastCFI: location.lastCFI,
- rangeCFI: location.rangeCFI
- )
- }
-
- return publication.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: controller.currentBookIdentifier
- ) ?? RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: location.href,
- progression: location.progression,
- lastProgression: location.lastProgression,
- fragment: location.fragment,
- rangeAnchor: location.rangeAnchor,
- cfi: location.cfi,
- lastCFI: location.lastCFI,
- rangeCFI: location.rangeCFI
- )
- }
-
- private func normalizedBookmarkNote(_ note: String?) -> String? {
- let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? nil : trimmed
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift
deleted file mode 100644
index 9179cbb..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderAssemblyCoordinator {
- private unowned let context: RDEPUBReaderContext
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func assembleInterface() {
- guard let controller = context.controller,
- let readerView = context.readerView else { return }
-
- controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
- setupReaderView(readerView, in: controller.view)
- setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
- setupErrorLabel(controller.errorLabel, in: controller.view)
- controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
- }
-
- func finishExternalTextBookLaunchIfNeeded() {
- guard let runtime = context.runtime,
- let controller = context.controller,
- context.isExternalTextBook else {
- return
- }
-
- let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
- if let id = context.currentBookIdentifier {
- context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
- context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
- }
-
- if let textBook = controller.textBook {
- runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
- } else {
- runtime.finishPagination(restoreLocation: restoreLocation)
- }
- }
-
- private func setupReaderView(_ readerView: RDEpubReaderView, in containerView: UIView) {
- readerView.pageProvider = context.controller
- readerView.delegate = context.controller
- readerView.translatesAutoresizingMaskIntoConstraints = false
- containerView.addSubview(readerView)
- NSLayoutConstraint.activate([
- readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
- readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
- readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
- ])
-
- readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
- readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
- readerView.register(
- contentView: RDEPUBTrialWallContainerView.self,
- contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTrialWallContainerView.self)
- )
- context.controller?.applyReaderViewConfiguration()
- }
-
- private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
- loadingIndicator.hidesWhenStopped = true
- loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
- containerView.addSubview(loadingIndicator)
- NSLayoutConstraint.activate([
- loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
- loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
- ])
- }
-
- private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
- errorLabel.translatesAutoresizingMaskIntoConstraints = false
- containerView.addSubview(errorLabel)
- NSLayoutConstraint.activate([
- errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
- errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
- errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
- ])
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift
deleted file mode 100644
index b714576..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift
+++ /dev/null
@@ -1,235 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
-
- private unowned let context: RDEPUBReaderContext
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- private var controller: RDEPUBReaderController? {
- context.controller
- }
-
- func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
-
- context.runtime?.settingsPanelDidDisappear()
- }
-
- func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
- let toolView: RDEPUBReaderTopToolViewProviding =
- context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView()
- toolView.onBack = { [weak self] in
- self?.handleBackAction()
- }
- toolView.onSearch = { [weak self] in
- self?.toggleSearchBar()
- }
- toolView.onToggleBookmark = { [weak self] in
- _ = self?.context.runtime?.toggleBookmark()
- }
- return toolView
- }
-
- func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
- let toolView: RDEPUBReaderBottomToolViewProviding =
- context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView()
- toolView.onShowTableOfContents = { [weak self] in
- self?.presentTableOfContents()
- }
- toolView.onShowBookmarks = { [weak self] in
- self?.context.runtime?.presentBookmarksManager()
- }
- toolView.onShowHighlights = { [weak self] in
- self?.context.runtime?.presentHighlightsManager()
- }
- toolView.onAddHighlight = { [weak self] in
- self?.context.runtime?.presentAnnotationCreation()
- }
- toolView.onShowSettings = { [weak self] in
- self?.presentSettings()
- }
- return toolView
- }
-
- func updateReaderChrome() {
- guard let controller else { return }
- let uiState = makeUIState()
- applyUIState(uiState)
- updateSearchBar()
- }
-
- func makeUIState() -> RDEPUBReaderUIState {
- guard let controller else { return .empty }
- return RDEPUBReaderUIState(
- canToggleBookmark: controller.currentBookIdentifier != nil,
- hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
- canShowBookmarks: !controller.activeBookmarks.isEmpty,
- canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
- canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
- showsTableOfContents: controller.configuration.showsTableOfContents,
- allowsHighlights: controller.configuration.allowsHighlights,
- showsSettingsPanel: controller.configuration.showsSettingsPanel
- )
- }
-
- func applyUIState(_ state: RDEPUBReaderUIState) {
- guard let controller else { return }
- controller.topToolView.apply(theme: controller.configuration.theme)
- controller.topToolView.setTitle(
- controller.title
- ?? controller.parser?.metadata.title
- ?? controller.epubURL.deletingPathExtension().lastPathComponent
- )
- controller.topToolView.setBookmarkEnabled(state.canToggleBookmark)
- controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation)
- controller.bottomToolView.apply(theme: controller.configuration.theme)
- controller.bottomToolView.updateVisibility(
- showsTableOfContents: state.showsTableOfContents,
- allowsHighlights: state.allowsHighlights,
- showsSettingsPanel: state.showsSettingsPanel
- )
- controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks)
- controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight)
- controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
- }
-
- private func hasBookmarkAtCurrentLocation() -> Bool {
- guard let controller else { return false }
- return context.runtime?.annotationCoordinator.currentBookmark() != nil
- }
-
- func presentSettings() {
- guard let controller else { return }
- guard controller.configuration.showsSettingsPanel else { return }
-
- context.runtime?.settingsPanelWillAppear()
-
- let settingsController = RDEPUBReaderSettingsViewController(
- configuration: controller.configuration,
- brightness: controller.currentBrightness
- )
- settingsController.onBrightnessChange = { [weak controller] brightness in
- controller?.setScreenBrightness(brightness)
- }
- settingsController.onFontSizeChange = { [weak controller] fontSize in
- controller?.updateConfiguration { $0.fontSize = fontSize }
- }
- settingsController.onFontChoiceChange = { [weak controller] fontChoice in
- controller?.updateConfiguration { $0.fontChoice = fontChoice }
- }
- settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
- controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
- }
- settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
- controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
- }
- settingsController.onDisplayTypeChange = { [weak controller] displayType in
- controller?.updateConfiguration { $0.displayType = displayType }
- }
- settingsController.onThemeChange = { [weak controller] theme in
- controller?.updateConfiguration { $0.theme = theme }
- }
- settingsController.onDismiss = { [weak self] in
-
- self?.context.runtime?.settingsPanelDidDisappear()
- }
-
- let navigationController = UINavigationController(rootViewController: settingsController)
- navigationController.modalPresentationStyle = .pageSheet
- navigationController.presentationController?.delegate = self
- controller.present(navigationController, animated: true)
- }
-
- func presentTableOfContents() {
- guard let controller else { return }
- guard controller.configuration.showsTableOfContents else { return }
- let items = controller.flattenedTableOfContentsItems(
- from: controller.publication?.tableOfContents ?? [],
- includePageNumbers: false
- )
- guard !items.isEmpty else { return }
-
- let chapterController = RDEPUBReaderChapterListController(
- items: items,
- currentItem: controller.currentTableOfContentsItem,
- theme: controller.configuration.theme
- )
- chapterController.onSelectItem = { [weak controller] item in
- guard let controller else { return }
- chapterController.dismiss(animated: true) {
- _ = controller.go(toTableOfContentsItem: item, animated: true)
- }
- }
-
- let navigationController = UINavigationController(rootViewController: chapterController)
- navigationController.modalPresentationStyle = .pageSheet
- controller.present(navigationController, animated: true)
- }
-
- func toggleSearchBar() {
- guard let controller else { return }
- if controller.isSearchBarVisible {
- controller.hideSearchBar()
- } else {
- controller.showSearchBar()
- }
- }
-
- func updateSearchBar() {
- guard let controller else { return }
- controller.searchBarView.apply(theme: controller.configuration.theme)
- if let searchState = controller.searchState {
- if let index = searchState.currentMatchIndex {
- controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
- } else if searchState.matches.isEmpty {
- controller.searchBarView.showNoResults()
- }
- }
- }
-
- func handleBackAction() {
- guard let controller else { return }
- close(controller)
- }
-
- private func close(_ controller: UIViewController) {
- let target = closestDismissTarget(from: controller)
- if let navigationController = target.navigationController,
- navigationController.viewControllers.first !== target {
- navigationController.popViewController(animated: true)
- return
- }
-
- if let navigationController = target.navigationController,
- navigationController.presentingViewController != nil {
- navigationController.dismiss(animated: true)
- return
- }
-
- if target.presentingViewController != nil {
- target.dismiss(animated: true)
- return
- }
-
- controller.dismiss(animated: true)
- }
-
- private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
- var candidate: UIViewController = controller
- var current = controller.parent
- while let parent = current {
- if let navigationController = parent.navigationController,
- navigationController.viewControllers.contains(parent) {
- return parent
- }
- if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
- return parent
- }
- candidate = parent
- current = parent.parent
- }
- return candidate
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
deleted file mode 100644
index cb325a5..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
+++ /dev/null
@@ -1,420 +0,0 @@
-import UIKit
-
-/// Captures layout parameters on the main thread for safe use on background queues.
-/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread.
-struct RDEPUBLayoutSnapshot {
- let pageSize: CGSize
- let style: RDEPUBTextRenderStyle
- let layoutConfig: RDEPUBTextLayoutConfig
- let renderSignature: String
-}
-
-final class RDEPUBReaderContext {
-
- private let activityLock = NSLock()
-
- private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
-
- weak var controller: RDEPUBReaderController?
-
- weak var readerView: RDEpubReaderView?
-
- let state: RDEPUBReaderState
-
- let environment: RDEPUBReaderEnvironment
-
- let services: RDEPUBReaderServices
-
- var dependencies: RDEPUBReaderDependencies {
- get { services.dependencies }
- set {
- services.dependencies = newValue
- environment.displayEnvironment = newValue.environment
- }
- }
-
- var runtime: RDEPUBReaderRuntime? {
- controller?.runtime
- }
-
- var parser: RDEPUBParser? {
- get { state.parser }
- set { state.parser = newValue }
- }
-
- var publication: RDEPUBPublication? {
- get { state.publication }
- set { state.publication = newValue }
- }
-
- var readingSession: RDEPUBReadingSession? {
- get { state.readingSession }
- set { state.readingSession = newValue }
- }
-
- var textBook: RDEPUBTextBook? {
- get { state.textBook }
- set { state.textBook = newValue }
- }
-
- var bookPageMap: RDEPUBBookPageMap? {
- get { state.bookPageMap }
- set { state.bookPageMap = newValue }
- }
-
- var activeBookmarks: [RDEPUBBookmark] {
- get { state.activeBookmarks }
- set { state.activeBookmarks = newValue }
- }
-
- var activeHighlights: [RDEPUBHighlight] {
- get { state.activeHighlights }
- set { state.activeHighlights = newValue }
- }
-
- var currentBookIdentifier: String? {
- get { state.currentBookIdentifier }
- set { state.currentBookIdentifier = newValue }
- }
-
- var paginationToken: UUID {
- get { state.paginationToken }
- set { state.paginationToken = newValue }
- }
-
- var searchState: RDEPUBSearchState? {
- get { state.searchState }
- set { state.searchState = newValue }
- }
-
- var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
- get { state.pendingPageMapUpdates }
- set { state.pendingPageMapUpdates = newValue }
- }
-
- var lastTextPaginationPageSize: CGSize? {
- get { state.lastTextPaginationPageSize }
- set { state.lastTextPaginationPageSize = newValue }
- }
-
- var lastMetadataParseWallClockMs: Int {
- get { state.lastMetadataParseWallClockMs }
- set { state.lastMetadataParseWallClockMs = newValue }
- }
-
- var lastMetadataParseConcurrency: Int {
- get { state.lastMetadataParseConcurrency }
- set { state.lastMetadataParseConcurrency = newValue }
- }
-
- var currentSelection: RDEPUBSelection? {
- get { state.currentSelection }
- set { state.currentSelection = newValue }
- }
-
- var selectionState: RDEPUBSelectionState {
- get { state.selectionState }
- set { state.selectionState = newValue }
- }
-
- var configuration: RDEPUBReaderConfiguration = .default
-
- var persistence: RDEPUBReaderPersistence?
-
- var epubURL: URL = URL(string: "about:blank")!
-
- var isRepaginating: Bool {
- get { state.isRepaginating }
- set { state.isRepaginating = newValue }
- }
-
- var didStartInitialLoad: Bool {
- get { state.didStartInitialLoad }
- set { state.didStartInitialLoad = newValue }
- }
-
- var isExternalTextBook: Bool {
- get { state.isExternalTextBook }
- set { state.isExternalTextBook = newValue }
- }
-
- var textFileURL: URL? {
- get { state.textFileURL }
- set { state.textFileURL = newValue }
- }
-
- var textBookCache: RDEPUBTextBookCache { state.textBookCache }
-
- init(controller: RDEPUBReaderController) {
- self.controller = controller
- self.readerView = controller.readerView
- let state = RDEPUBReaderState()
- self.state = state
- self.environment = RDEPUBReaderEnvironment(
- controller: controller,
- readerView: controller.readerView,
- displayEnvironment: RDEPUBUIScreenEnvironment()
- )
- self.services = RDEPUBReaderServices(dependencies: .live)
- }
-
- func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
- environment.currentLayoutContext(configuration: configuration)
- }
-
- func currentPreferences() -> RDEPUBPreferences {
- let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
- return configuration.makePreferences(safeAreaInsets: safeInsets)
- }
-
- /// Captures all layout parameters needed for background chapter loading.
- /// Must be called on the main thread. The returned snapshot is safe to use on any thread.
- func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot {
- dispatchPrecondition(condition: .onQueue(.main))
- let pageSize = currentTextPageSize()
- let style = currentTextRenderStyle()
- let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
- let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
- return RDEPUBLayoutSnapshot(
- pageSize: pageSize,
- style: style,
- layoutConfig: layoutConfig,
- renderSignature: renderSignature
- )
- }
-
- func currentTextPageSize() -> CGSize {
- // M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too.
- dispatchPrecondition(condition: .onQueue(.main))
-
- let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
- if let readerView, let pageNum {
- let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
- if resolvedSize.width > 0, resolvedSize.height > 0 {
- return resolvedSize
- }
- }
- let viewportSize = currentLayoutContext().viewportSize
- if viewportSize.width > 0, viewportSize.height > 0 {
- return viewportSize
- }
- if let lastTextPaginationPageSize,
- lastTextPaginationPageSize.width > 0,
- lastTextPaginationPageSize.height > 0 {
- return lastTextPaginationPageSize
- }
- return environment.fallbackViewportSize
- }
-
- func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
- dispatchPrecondition(condition: .onQueue(.main))
- return environment.currentTextRenderStyle(configuration: configuration)
- }
-
- func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
- dispatchPrecondition(condition: .onQueue(.main))
- return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
- }
-
- func resolvedTextRenderer() -> RDEPUBTextRenderer {
- services.resolvedTextRenderer(configuration: configuration)
- }
-
- var activePages: [EPUBPage] {
- state.activePages
- }
-
- var activeChapters: [EPUBChapterInfo] {
- state.activeChapters
- }
-
- var currentBrightness: CGFloat {
- get { environment.currentBrightness }
- set { environment.currentBrightness = newValue }
- }
-
- func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
- state.replaceActiveSnapshot(snapshot)
- }
-
- func clearActiveSnapshot() {
- state.clearActiveSnapshot()
- }
-
- func makeParser() -> RDEPUBParser {
- services.makeParser()
- }
-
- func makePaginator() -> RDEPUBPaginator {
- services.makePaginator()
- }
-
- func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
- services.makeTextBookBuilder(
- configuration: configuration,
- cache: textBookCache,
- layoutConfig: layoutConfig
- )
- }
-
- func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
- services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
- }
-
- func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
- let contentHash: String
- if let parser,
- let publication,
- publication.spine.indices.contains(spineIndex) {
- let href = publication.spine[spineIndex].href
- contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
- } else {
- contentHash = ""
- }
- return chapterCacheKey(
- forSpineIndex: spineIndex,
- precomputedContentHash: contentHash,
- renderSignature: currentRenderSignature()
- )
- }
-
- func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
- chapterCacheKey(
- forSpineIndex: spineIndex,
- precomputedContentHash: precomputedContentHash,
- renderSignature: currentRenderSignature()
- )
- }
-
- func chapterCacheKey(
- forSpineIndex spineIndex: Int,
- precomputedContentHash: String,
- renderSignature: String
- ) -> RDEPUBChapterCacheKey {
- RDEPUBChapterCacheKey(
- bookID: currentBookIdentifier ?? "",
- spineIndex: spineIndex,
- renderSignature: renderSignature,
- chapterContentHash: precomputedContentHash
- )
- }
-
- func currentRenderSignature() -> String {
- dispatchPrecondition(condition: .onQueue(.main))
- let style = currentTextRenderStyle()
- let pageSize = currentTextPageSize()
- let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
- return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
- }
-
- private func renderSignature(
- style: RDEPUBTextRenderStyle,
- pageSize: CGSize,
- layoutConfig: RDEPUBTextLayoutConfig
- ) -> String {
- [
- style.font.fontName,
- "\(style.font.pointSize)",
- "\(configuration.lineHeightMultiple)",
- "\(style.lineSpacing)",
- layoutConfig.cacheSignature,
- "\(RDEPUBChapterSummary.currentSchemaVersion)"
- ].joined(separator: "|")
- }
-
- func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
- runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
- }
-
- func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
- guard let publication else { return nil }
- let normalizedLocation = publication.resourceResolver.normalizedLocation(
- location,
- relativeToSpineIndex: nil,
- bookIdentifier: currentBookIdentifier
- ) ?? location
- guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
- return nil
- }
- return publication.spine.firstIndex {
- publication.resourceResolver.normalizedHref($0.href) == normalizedHref
- }
- }
-
- func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder {
- services.makePlainTextBookBuilder(
- configuration: configuration,
- layoutConfig: layoutConfig
- )
- }
-
- func currentVisibleLocation() -> RDEPUBLocation? {
- controller?.currentVisibleLocation()
- }
-
- func persistenceLocation() -> RDEPUBLocation? {
- guard let currentBookIdentifier else { return nil }
- return persistence?.loadLocation(for: currentBookIdentifier)
- }
-
- func persist(location: RDEPUBLocation) {
- guard let currentBookIdentifier else { return }
- persistence?.saveLocation(location, for: currentBookIdentifier)
- }
-
- func markUserNavigationActivity() {
- activityLock.lock()
- lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
- activityLock.unlock()
- }
-
- func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
- activityLock.lock()
- let timestamp = lastUserNavigationTimestamp
- activityLock.unlock()
- guard timestamp > 0 else { return .greatestFiniteMagnitude }
- return CFAbsoluteTimeGetCurrent() - timestamp
- }
-
- func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
- guard let textBook else { return nil }
- // External text books (e.g. plain .txt) have no publication; their
- // chapter hrefs are matched verbatim.
- 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) }
- }
-
- func showLoading() {
- controller?.showLoading()
- }
-
- func hideLoading() {
- controller?.hideLoading()
- }
-
- func handle(error: Error) {
- controller?.handle(error: error)
- }
-
- func updateReaderChrome() {
- controller?.updateReaderChrome()
- }
-
- func refreshVisibleContentPreservingLocation() {
- controller?.refreshVisibleContentPreservingLocation()
- }
-
- func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
- controller?.restoreReadingLocation(location, animated: animated) ?? false
- }
-
- func repaginatePreservingCurrentLocation() {
- controller?.repaginatePreservingCurrentLocation()
- }
-
- func applyReaderViewConfiguration() {
- controller?.applyReaderViewConfiguration()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift
deleted file mode 100644
index 17490f0..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift
+++ /dev/null
@@ -1,97 +0,0 @@
-
-import UIKit
-
-public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
-
- var currentBrightness: CGFloat { get set }
-
- var fallbackViewportSize: CGSize { get }
-}
-
-public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
- public init() {}
-
- public var currentBrightness: CGFloat {
- get { CGFloat(UIScreen.main.brightness) }
- set { UIScreen.main.brightness = newValue }
- }
-
- public var fallbackViewportSize: CGSize {
- UIScreen.main.bounds.size
- }
-}
-
-public struct RDEPUBReaderDependencies {
-
- public var environment: any RDEPUBReaderDisplayEnvironment
-
- public var makeParser: () -> RDEPUBParser
-
- public var makePaginator: () -> RDEPUBPaginator
-
- public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
-
- public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
-
- public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
-
- /// 自定义顶部工具栏工厂;nil 时使用内置 `RDEPUBReaderTopToolView`
- public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)?
-
- /// 自定义底部工具栏工厂;nil 时使用内置 `RDEPUBReaderBottomToolView`
- public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)?
-
- public init(
- environment: any RDEPUBReaderDisplayEnvironment,
- makeParser: @escaping () -> RDEPUBParser,
- makePaginator: @escaping () -> RDEPUBPaginator,
- makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
- makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder,
- makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer,
- makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil,
- makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil
- ) {
- self.environment = environment
- self.makeParser = makeParser
- self.makePaginator = makePaginator
- self.makeTextBookBuilder = makeTextBookBuilder
- self.makePlainTextBookBuilder = makePlainTextBookBuilder
- self.makeTextRenderer = makeTextRenderer
- self.makeTopToolView = makeTopToolView
- self.makeBottomToolView = makeBottomToolView
- }
-
- /// 构建带加密资源 provider 的默认依赖:
- /// 宿主实现 `RDEPUBResourceDataProvider` 后经此注入,即可打开单文件加密的 EPUB。
- /// - Parameter resourceDataProvider: 解密数据提供者;传 nil 等价于 `.live`
- public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies {
- var dependencies = RDEPUBReaderDependencies.live
- guard let resourceDataProvider else { return dependencies }
- dependencies.makeParser = {
- let parser = RDEPUBParser()
- parser.resourceDataProvider = resourceDataProvider
- return parser
- }
- return dependencies
- }
-
- public static var live: RDEPUBReaderDependencies {
- RDEPUBReaderDependencies(
- environment: RDEPUBUIScreenEnvironment(),
- makeParser: { RDEPUBParser() },
- makePaginator: { RDEPUBPaginator() },
- makeTextBookBuilder: { renderer, cache, layoutConfig in
- RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
- },
- makePlainTextBookBuilder: { renderer, layoutConfig in
- RDEpubPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
- },
- makeTextRenderer: { engine in
- switch engine {
- case .dtCoreText:
- return RDEPUBDTCoreTextRenderer()
- }
- }
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift
deleted file mode 100644
index c15edae..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift
+++ /dev/null
@@ -1,98 +0,0 @@
-import UIKit
-
-enum RDEPUBTextPageLayoutMetrics {
-
- static let pageNumberTrailingPadding: CGFloat = 4
-
- static let pageNumberFooterPadding: CGFloat = 8
-
- static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
-
- static func contentInsets(
- configuration: RDEPUBReaderConfiguration,
- safeAreaInsets: UIEdgeInsets
- ) -> UIEdgeInsets {
- let configInsets = configuration.reflowableContentInsets
- return UIEdgeInsets(
- top: max(configInsets.top, safeAreaInsets.top),
- left: max(configInsets.left, safeAreaInsets.left),
- bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
- right: max(configInsets.right, safeAreaInsets.right)
- )
- }
-}
-
-final class RDEPUBReaderEnvironment {
-
- weak var controller: RDEPUBReaderController?
-
- weak var readerView: RDEpubReaderView?
-
- var displayEnvironment: any RDEPUBReaderDisplayEnvironment
-
- init(
- controller: RDEPUBReaderController,
- readerView: RDEpubReaderView,
- displayEnvironment: any RDEPUBReaderDisplayEnvironment
- ) {
- self.controller = controller
- self.readerView = readerView
- self.displayEnvironment = displayEnvironment
- }
-
- func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> RDEPUBNavigatorLayoutContext {
- let containerSize = readerView?.bounds.size ?? .zero
- let viewSize = controller?.view.bounds.size ?? containerSize
- let resolvedSize = containerSize == .zero ? viewSize : containerSize
- return RDEPUBNavigatorLayoutContext(
- containerSize: resolvedSize,
- pagesPerScreen: readerView?.pagesPerScreen ?? 1,
- safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
- userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
- reflowableContentInsets: configuration.reflowableContentInsets
- )
- }
-
- func currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderStyle {
- let font = configuration.fontChoice.font(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
- )
- }
-
- func currentTextLayoutConfig(
- configuration: RDEPUBReaderConfiguration,
- pageSize: CGSize
- ) -> RDEPUBTextLayoutConfig {
- let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
- return RDEPUBTextLayoutConfig(
- frameWidth: max(pageSize.width, 1),
- frameHeight: max(pageSize.height, 1),
- edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
- configuration: configuration,
- safeAreaInsets: safeAreaInsets
- ),
- numberOfColumns: configuration.numberOfColumns,
- columnGap: configuration.columnGap,
- avoidOrphans: false,
- avoidWidows: false,
- avoidPageBreakInsideEnabled: true,
- hyphenation: true,
- imageMaxHeightRatio: 0.85,
- fallbackViewportSize: displayEnvironment.fallbackViewportSize
- )
- }
-
- var currentBrightness: CGFloat {
- get { displayEnvironment.currentBrightness }
- set { displayEnvironment.currentBrightness = newValue }
- }
-
- var fallbackViewportSize: CGSize {
- displayEnvironment.fallbackViewportSize
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift
deleted file mode 100644
index f98286f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift
+++ /dev/null
@@ -1,86 +0,0 @@
-import Foundation
-
-final class RDEPUBReaderLoadCoordinator {
- private weak var context: RDEPUBReaderContext?
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func startInitialLoadIfNeeded() {
- guard let context, let controller = context.controller,
- let readerView = context.readerView,
- !controller.didStartInitialLoad,
- readerView.bounds.width > 0,
- readerView.bounds.height > 0 else {
- return
- }
- controller.didStartInitialLoad = true
- loadPublication()
- }
-
- func loadPublication() {
- guard let context, let controller = context.controller else { return }
- context.showLoading()
- let loadToken = UUID()
- context.paginationToken = loadToken
-
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
- guard let self, let context = self.context, let controller = context.controller else { return }
- let parser = context.makeParser()
-
- do {
- try parser.parse(epubURL: controller.epubURL)
- let publication = parser.makePublication()
- let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
- let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
- let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
- let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
-
- DispatchQueue.main.async { [weak self] in
- guard let self, let context = self.context else { return }
- guard context.paginationToken == loadToken else { return }
- context.runtime?.applyParsedPublication(
- parser: parser,
- publication: publication,
- bookIdentifier: bookIdentifier,
- restoreLocation: restoreLocation,
- bookmarks: bookmarks,
- highlights: highlights
- )
- }
- } catch {
- DispatchQueue.main.async { [weak self] in
- guard let self, let context = self.context else { return }
- guard context.paginationToken == loadToken else { return }
- context.handle(error: error)
- }
- }
- }
- }
-
- func applyParsedPublication(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- bookIdentifier: String,
- restoreLocation: RDEPUBLocation?,
- bookmarks: [RDEPUBBookmark],
- highlights: [RDEPUBHighlight]
- ) {
- guard let context, let controller = context.controller else { return }
- context.parser = parser
- context.publication = publication
- context.currentBookIdentifier = bookIdentifier
- context.activeBookmarks = bookmarks
- context.activeHighlights = highlights
- context.readingSession = RDEPUBReadingSession(publication: publication)
- controller.title = parser.metadata.title.isEmpty
- ? controller.epubURL.deletingPathExtension().lastPathComponent
- : parser.metadata.title
- controller.applyReaderViewConfiguration()
- context.updateReaderChrome()
- controller.delegate?.epubReader(controller, didOpen: publication)
- controller.applyOrientationLockIfNeeded()
- context.runtime?.paginatePublication(restoreLocation: restoreLocation)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift
deleted file mode 100644
index 0c34441..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift
+++ /dev/null
@@ -1,125 +0,0 @@
-import Foundation
-
-final class RDEPUBReaderLocationCoordinator {
- private unowned let context: RDEPUBReaderContext
-
- private var lastPageChangeSpineIndex: Int?
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- @discardableResult
- func restoreReadingLocation(
- _ location: RDEPUBLocation,
- animated: Bool = false,
- targetHighlightRangeInfo: String? = nil
- ) -> Bool {
- guard let controller = context.controller,
- let readerView = context.readerView else { return false }
- if context.bookPageMap != nil {
- _ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
- }
- guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
- readerView.transitionToPage(pageNum: 0)
- return false
- }
-
- if context.bookPageMap != nil {
- guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
- return false
- }
- _ = context.readingSession?.queueNavigation(
- to: location,
- relativeToSpineIndex: nil,
- bookIdentifier: context.currentBookIdentifier,
- targetHighlightRangeInfo: targetHighlightRangeInfo
- )
- } else if context.textBook == nil {
- _ = context.readingSession?.queueNavigation(
- to: location,
- relativeToSpineIndex: nil,
- bookIdentifier: context.currentBookIdentifier,
- targetHighlightRangeInfo: targetHighlightRangeInfo
- )
- } else {
- }
- readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
-
- recordPageChangeIfNeeded()
-
- return true
- }
-
- func currentVisibleLocation() -> RDEPUBLocation? {
- guard let controller = context.controller,
- let readerView = context.readerView else {
- return nil
- }
- let pageNumber = readerView.currentPage + 1
- if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
- if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
- return location
- }
-
- if let readingSession = context.readingSession,
- readingSession.activePages.indices.contains(readerView.currentPage) {
- return readingSession.fallbackLocation(
- for: readingSession.activePages[readerView.currentPage],
- bookIdentifier: context.currentBookIdentifier
- )
- }
- }
- return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
- }
-
- func persistenceLocation() -> RDEPUBLocation? {
- guard let controller = context.controller,
- let currentBookIdentifier = context.currentBookIdentifier else {
- return nil
- }
- return controller.persistence?.loadLocation(for: currentBookIdentifier)
- }
-
- func persist(location: RDEPUBLocation) {
- guard let controller = context.controller,
- let currentBookIdentifier = context.currentBookIdentifier else { return }
- controller.persistence?.saveLocation(location, for: currentBookIdentifier)
- controller.delegate?.epubReader(controller, didUpdateLocation: location)
- controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
- controller.updateReaderChrome()
- }
-
- func recordPageChangeIfNeeded() {
- guard let runtime = context.runtime,
- let bookPageMap = context.bookPageMap,
- let readerView = context.readerView else { return }
-
- let currentPageNumber = readerView.currentPage + 1
- guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
- return
- }
-
- if let lastSpineIndex = lastPageChangeSpineIndex,
- lastSpineIndex != currentSpineIndex {
- runtime.jumpSessionManager.recordPageChange(
- fromSpineIndex: lastSpineIndex,
- toSpineIndex: currentSpineIndex
- )
- }
-
- lastPageChangeSpineIndex = currentSpineIndex
-
- let isIdle = context.secondsSinceLastUserNavigation() > 2.0
- if let endReason = runtime.jumpSessionManager.checkSessionEnd(
- currentSpineIndex: currentSpineIndex,
- isIdle: isIdle
- ) {
- runtime.jumpSessionManager.endSession(endReason)
- }
- }
-
- func resetPageChangeState() {
- lastPageChangeSpineIndex = nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift
deleted file mode 100644
index 6134c66..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift
+++ /dev/null
@@ -1,387 +0,0 @@
-import Foundation
-
-final class RDEPUBReaderPaginationCoordinator {
-
- static var pageMapRefreshInterval: Int = 32
-
- private unowned let context: RDEPUBReaderContext
-
- private let metadataParseControlLock = NSLock()
-
- private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func paginatePublication(restoreLocation: RDEPUBLocation?) {
- guard let controller = context.controller,
- let parser = context.parser,
- let publication = context.publication,
- let readingSession = context.readingSession else {
- return
- }
-
- controller.isRepaginating = true
- controller.errorLabel.isHidden = true
- controller.showLoading()
- let token = UUID()
- context.paginationToken = token
-
- if publication.readingProfile == .textReflowable {
- paginateTextPublication(
- parser: parser,
- publication: publication,
- readingSession: readingSession,
- restoreLocation: restoreLocation,
- token: token
- )
- return
- }
-
- if publication.layout == .fixed {
- let snapshot = readingSession.makePaginationSnapshot(
- pageCounts: Array(repeating: 1, count: publication.spine.count),
- preferences: controller.currentPreferences(),
- layoutContext: controller.currentLayoutContext()
- )
- context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
- return
- }
-
- let paginator = context.makePaginator()
- paginator.calculate(
- parser: parser,
- hostingView: controller.ensurePaginationHostView(),
- presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
- ) { [weak controller] pageCounts in
- guard let controller, self.context.paginationToken == token else { return }
- let snapshot = readingSession.makePaginationSnapshot(
- pageCounts: pageCounts,
- preferences: controller.currentPreferences(),
- layoutContext: controller.currentLayoutContext()
- )
- self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
- }
- }
-
- func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
- guard let controller = context.controller else { return }
- context.textBook = textBook
- context.bookPageMap = nil
- context.pendingPageMapUpdates.removeAll()
- let snapshot = controller.nativeTextSnapshot(from: textBook)
- context.replaceActiveSnapshot(snapshot)
-
- guard !textBook.pages.isEmpty else {
- context.handle(error: RDEPUBParserError.emptySpine)
- return
- }
-
- finishPagination(restoreLocation: restoreLocation)
- }
-
- func applyPaginationSnapshot(
- _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
- restoreLocation: RDEPUBLocation?
- ) {
- guard context.controller != nil else { return }
- context.textBook = nil
- context.bookPageMap = nil
- context.pendingPageMapUpdates.removeAll()
- context.replaceActiveSnapshot(snapshot)
-
- guard !snapshot.pages.isEmpty else {
- context.handle(error: RDEPUBParserError.emptySpine)
- return
- }
-
- finishPagination(restoreLocation: restoreLocation)
- }
-
- func finishPagination(restoreLocation: RDEPUBLocation?) {
- guard let controller = context.controller,
- let readerView = context.readerView else { return }
- controller.isRepaginating = false
- controller.hideLoading()
- readerView.reloadData()
- if let targetLocation = restoreLocation {
- controller.restoreReadingLocation(targetLocation)
- } else {
- readerView.transitionToPage(pageNum: 0)
- }
-
- context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
- }
-
- func repaginatePreservingCurrentLocation(
- preferredRestoreLocation: RDEPUBLocation? = nil
- ) {
- guard context.publication != nil else { return }
- let restoreLocation = preferredRestoreLocation
- ?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
- ?? context.currentVisibleLocation()
- ?? context.persistenceLocation()
- paginatePublication(restoreLocation: restoreLocation)
- }
-
- func refreshVisibleContentPreservingLocation() {
- guard let readerView = context.readerView else { return }
- if readerView.isPageCurlTransitioning {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
- self?.refreshVisibleContentPreservingLocation()
- }
- return
- }
- let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
- if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
- readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
- } else {
- readerView.reloadData()
- }
- if let restoreLocation {
- _ = context.restoreReadingLocation(restoreLocation)
- }
- }
-
- func rebuildExternalTextBook() {
- guard let controller = context.controller,
- let textFileURL = controller.textFileURL else { return }
- let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
- let pageSize = controller.currentTextPageSize()
- let style = controller.currentTextRenderStyle()
- let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
- if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
- context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
- }
- }
-
- private func paginateTextPublication(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- readingSession: RDEPUBReadingSession,
- restoreLocation: RDEPUBLocation?,
- token: UUID
- ) {
- guard let controller = context.controller else { return }
- let context = self.context
-
- let pageSize = controller.currentTextPageSize()
- context.lastTextPaginationPageSize = pageSize
- let layoutSnapshot = context.makeLayoutSnapshot()
- let runtime = context.runtime
-
- DispatchQueue.global(qos: .utility).async { [weak controller] in
- guard controller != nil else { return }
- guard let runtime else { return }
- let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
- publication: publication,
- readingSession: readingSession,
- restoreLocation: restoreLocation
- )
- guard prioritizedCandidates.first != nil else {
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil else { return }
- context.handle(error: RDEPUBParserError.emptySpine)
- }
- return
- }
-
- do {
- let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
- prioritizedSpineIndices: prioritizedCandidates,
- runtime: runtime,
- layoutSnapshot: layoutSnapshot
- )
- RDEPUBBackgroundTrace.log(
- "Pagination",
- "anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
- )
- let initialChapters = self.loadInitialInteractiveRuntimeChapters(
- anchorChapter: runtimeChapter,
- publication: publication,
- runtime: runtime,
- layoutSnapshot: layoutSnapshot
- )
- let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
- RDEPUBBackgroundTrace.log(
- "Pagination",
- "initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
- )
-
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil else { return }
- runtime.chapterRuntimeStore.setCurrentChapter(
- spineIndex: runtimeChapter.spineIndex,
- totalSpineCount: publication.spine.count,
- windowRadius: context.configuration.chapterWindowRadius
- )
- let partialMap = self.makePartialPageMap(from: initialChapters)
- runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
- runtime.prefetchForwardChaptersAfterInitialOpen(
- anchorSpineIndex: runtimeChapter.spineIndex,
- totalSpineCount: publication.spine.count
- )
- let cancellationController = self.beginMetadataParseCancellationController(for: token)
- let worker = RDEPUBMetadataParseWorker(
- context: context,
- cancellationController: cancellationController,
- token: token,
- parser: parser,
- publication: publication
- )
- RDEPUBBackgroundTrace.log(
- "Pagination",
- "startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
- )
- worker.start(token: token, restoreLocation: restoreLocation)
- }
- } catch {
- RDEPUBBackgroundTrace.log(
- "Pagination",
- "initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
- )
- DispatchQueue.main.async {
- guard context.paginationToken == token,
- context.controller != nil else { return }
- context.handle(error: error)
- }
- }
- }
- }
-
- private func loadFirstRenderableRuntimeChapter(
- prioritizedSpineIndices: [Int],
- runtime: RDEPUBReaderRuntime,
- layoutSnapshot: RDEPUBLayoutSnapshot
- ) throws -> RDEPUBRuntimeChapter {
- var lastError: Error?
- for spineIndex in prioritizedSpineIndices {
- do {
- return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
- spineIndex: spineIndex,
- store: runtime.chapterRuntimeStore,
- layoutSnapshot: layoutSnapshot
- )
- } catch {
- lastError = error
- }
- }
- throw lastError ?? RDEPUBParserError.emptySpine
- }
-
- private func loadInitialInteractiveRuntimeChapters(
- anchorChapter: RDEPUBRuntimeChapter,
- publication: RDEPUBPublication,
- runtime: RDEPUBReaderRuntime,
- layoutSnapshot: RDEPUBLayoutSnapshot
- ) -> [RDEPUBRuntimeChapter] {
- let minimumInteractivePageCount = 2
- let maximumAdditionalChapters = 1
-
- guard anchorChapter.pages.count < minimumInteractivePageCount else {
- return [anchorChapter]
- }
-
- let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
- guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else {
- return [anchorChapter]
- }
-
- var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter]
-
- for offset in 1...maximumAdditionalChapters {
- let candidatePositions = [anchorPosition + offset, anchorPosition - offset]
- for candidatePosition in candidatePositions {
- guard buildableSpineIndices.indices.contains(candidatePosition) else { continue }
- let spineIndex = buildableSpineIndices[candidatePosition]
- guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue }
-
- do {
- let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
- spineIndex: spineIndex,
- store: runtime.chapterRuntimeStore,
- layoutSnapshot: layoutSnapshot
- )
- selectedChapters.append(chapter)
- } catch {
- #if DEBUG
- print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
- #endif
- }
- }
-
- let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count }
- if loadedPageCount >= minimumInteractivePageCount {
- break
- }
- }
-
- return selectedChapters
- }
-
- private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
- var builder = RDEPUBBookPageMap.Builder()
- for chapter in chapters {
- builder.add(
- spineIndex: chapter.spineIndex,
- href: chapter.href,
- title: chapter.title,
- pageCount: chapter.pages.count,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
- )
- }
- return builder.build()
- }
-
- private func prioritizedBuildableSpineIndices(
- publication: RDEPUBPublication,
- readingSession: RDEPUBReadingSession,
- restoreLocation: RDEPUBLocation?
- ) -> [Int] {
- let preferred = readingSession.initialSpineIndex(for: restoreLocation)
- return publication.spine.indices
- .filter { isBuildableTextSpine(at: $0, in: publication) }
- .sorted { lhs, rhs in
- abs(lhs - preferred) < abs(rhs - preferred)
- }
- }
-
- private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
- publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
- }
-
- private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
- guard publication.spine.indices.contains(index) else { return false }
- let item = publication.spine[index]
- return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
- }
-
- func cancelActiveMetadataParseWork() {
- metadataParseControlLock.lock()
- let controller = activeMetadataParseCancellationController
- activeMetadataParseCancellationController = nil
- metadataParseControlLock.unlock()
- controller?.cancel()
- }
-
- func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
- metadataParseControlLock.lock()
- if activeMetadataParseCancellationController === controller {
- activeMetadataParseCancellationController = nil
- }
- metadataParseControlLock.unlock()
- }
-
- private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController {
- let controller = RDEPUBMetadataParseCancellationController(token: token)
- metadataParseControlLock.lock()
- let previous = activeMetadataParseCancellationController
- activeMetadataParseCancellationController = controller
- metadataParseControlLock.unlock()
- previous?.cancel()
- return controller
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift
deleted file mode 100644
index 2b36c62..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift
+++ /dev/null
@@ -1,689 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderRuntime {
-
- private unowned let context: RDEPUBReaderContext
-
- lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
-
- lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
-
- lazy var chapterLoader: RDEPUBChapterLoader = {
- let loader = RDEPUBChapterLoader(context: context)
- loader.setSummaryDiskCache(summaryDiskCache)
- loader.onDeferredCFIMapReady = { [weak self] spineIndex in
- self?.handleDeferredCFIMapReady(for: spineIndex)
- }
- return loader
- }()
-
- lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
-
- lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
-
- lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
-
- lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
-
- lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
-
- lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
-
- lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
-
- lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
-
- lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
-
- lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
-
- lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
-
- lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
-
- lazy var presentationRuntime = RDEPUBPresentationRuntime(
- context: context,
- locationCoordinator: locationCoordinator,
- jumpSessionManager: jumpSessionManager,
- reconciliationCoordinator: reconciliationCoordinator
- )
-
- lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
- context: context,
- store: chapterRuntimeStore,
- loader: chapterLoader,
- presentationRuntime: presentationRuntime,
- locationCoordinator: locationCoordinator,
- backgroundPriorityManager: backgroundPriorityManager,
- jumpSessionManager: jumpSessionManager,
- refreshVisibleContentPreservingLocation: { [weak self] in
- self?.refreshVisibleContentPreservingLocation()
- }
- )
-
- var isSettingsPanelOpen: Bool = false
-
- var needsFullRepaginationAfterSettingsClose: Bool = false
-
- private var settingsPreviewGeneration: Int = 0
-
- private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
-
- /// The location before the settings preview replaces the full book page map
- /// with the current chapter's temporary map. The temporary map rebases that
- /// chapter at page zero, so it must never be used as the final restore source.
- private var settingsRestoreLocation: RDEPUBLocation?
-
- /// A settings session must keep one immutable text offset. Re-capturing the
- /// start of each preview page makes repeated font-size changes drift backward.
- private var settingsPreviewAnchor: SettingsPreviewAnchor?
-
- private let settingsPreviewDebounceDelay: TimeInterval = 0.2
-
- private struct SettingsPreviewAnchor {
-
- let spineIndex: Int
-
- let href: String
-
- let offset: Int
- }
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
- chromeCoordinator.makeTopToolView()
- }
-
- func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
- chromeCoordinator.makeBottomToolView()
- }
-
- func startInitialLoadIfNeeded() {
- loadCoordinator.startInitialLoadIfNeeded()
- }
-
- func reloadBook() {
- guard let readerView = context.readerView else { return }
- context.didStartInitialLoad = false
- context.parser = nil
- context.publication = nil
- context.clearActiveSnapshot()
- context.readingSession = nil
- context.textBook = nil
- context.bookPageMap = nil
- context.pendingPageMapUpdates.removeAll()
- context.activeBookmarks = []
- context.activeHighlights = []
- context.searchState = nil
- clearOnDemandPageModeState()
- viewportMonitor.resetForReload()
- annotationCoordinator.updateCurrentSelection(nil)
- readerView.reloadData()
- startInitialLoadIfNeeded()
- }
-
- func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
- locationCoordinator.restoreReadingLocation(location, animated: animated)
- }
-
- @discardableResult
- func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
- guard context.controller != nil,
- let readerView = context.readerView,
- pageNumber > 0 else {
- return false
- }
-
- if let textBook = context.textBook {
- guard textBook.page(at: pageNumber) != nil else {
- return false
- }
- readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
- if let location = locationCoordinator.currentVisibleLocation() {
- context.persist(location: location)
- }
- return true
- }
-
- if context.bookPageMap != nil {
- guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
- return false
- }
- readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
- if let location = locationCoordinator.currentVisibleLocation() {
- context.persist(location: location)
- }
- return true
- }
-
- guard context.activePages.indices.contains(pageNumber - 1) else {
- return false
- }
- readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
- if let location = locationCoordinator.currentVisibleLocation() {
- context.persist(location: location)
- }
- return true
- }
-
- func clearSelection() {
- annotationCoordinator.updateCurrentSelection(nil)
- }
-
- func bookmark(withID id: String) -> RDEPUBBookmark? {
- annotationCoordinator.bookmark(withID: id)
- }
-
- func highlight(withID id: String) -> RDEPUBHighlight? {
- annotationCoordinator.highlight(withID: id)
- }
-
- @discardableResult
- func addHighlight(
- from selection: RDEPUBSelection? = nil,
- color: String = "#F8E16C",
- note: String? = nil
- ) -> RDEPUBHighlight? {
- annotationCoordinator.addHighlight(from: selection, color: color, note: note)
- }
-
- @discardableResult
- func addAnnotation(
- from selection: RDEPUBSelection? = nil,
- style: RDEPUBHighlightStyle,
- color: String = "#F8E16C",
- note: String? = nil
- ) -> RDEPUBHighlight? {
- annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
- }
-
- @discardableResult
- func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
- annotationCoordinator.upsertHighlight(highlight)
- }
-
- @discardableResult
- func removeHighlight(id: String) -> RDEPUBHighlight? {
- annotationCoordinator.removeHighlight(id: id)
- }
-
- @discardableResult
- func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
- annotationCoordinator.updateHighlightNote(id: id, note: note)
- }
-
- @discardableResult
- func go(toHighlightID id: String, animated: Bool = true) -> Bool {
- annotationCoordinator.go(toHighlightID: id, animated: animated)
- }
-
- func removeAllHighlights() {
- annotationCoordinator.removeAllHighlights()
- }
-
- @discardableResult
- func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
- annotationCoordinator.addBookmark(note: note)
- }
-
- @discardableResult
- func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
- annotationCoordinator.toggleBookmark(note: note)
- }
-
- @discardableResult
- func removeBookmark(id: String) -> RDEPUBBookmark? {
- annotationCoordinator.removeBookmark(id: id)
- }
-
- @discardableResult
- func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
- annotationCoordinator.go(toBookmarkID: id, animated: animated)
- }
-
- func presentBookmarksManager() {
- annotationCoordinator.presentBookmarksManager()
- }
-
- func presentHighlightsManager() {
- annotationCoordinator.presentHighlightsManager()
- }
-
- func presentAnnotationCreation() {
- annotationCoordinator.presentAnnotationCreation()
- }
-
- func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
- annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
- }
-
- func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
- annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
- }
-
- func search(keyword: String) {
- searchCoordinator.search(keyword: keyword)
- }
-
- @discardableResult
- func searchNext() -> Bool {
- searchCoordinator.searchNext()
- }
-
- @discardableResult
- func searchPrevious() -> Bool {
- searchCoordinator.searchPrevious()
- }
-
- @discardableResult
- func selectSearchMatch(at index: Int) -> Bool {
- searchCoordinator.selectSearchMatch(at: index)
- }
-
- func clearSearch() {
- searchCoordinator.clearSearch()
- }
-
- func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
- searchCoordinator.searchPresentation(for: page)
- }
-
- func updateReaderChrome() {
- chromeCoordinator.updateReaderChrome()
- }
-
- func presentSettings() {
- chromeCoordinator.presentSettings()
- }
-
- func presentTableOfContents() {
- chromeCoordinator.presentTableOfContents()
- }
-
- func handleBackAction() {
- chromeCoordinator.handleBackAction()
- }
-
- func loadPublication() {
- loadCoordinator.loadPublication()
- }
-
- func applyParsedPublication(
- parser: RDEPUBParser,
- publication: RDEPUBPublication,
- bookIdentifier: String,
- restoreLocation: RDEPUBLocation?,
- bookmarks: [RDEPUBBookmark],
- highlights: [RDEPUBHighlight]
- ) {
- loadCoordinator.applyParsedPublication(
- parser: parser,
- publication: publication,
- bookIdentifier: bookIdentifier,
- restoreLocation: restoreLocation,
- bookmarks: bookmarks,
- highlights: highlights
- )
- }
-
- func paginatePublication(restoreLocation: RDEPUBLocation?) {
- paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
- }
-
- func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
- paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
- }
-
- func applyPaginationSnapshot(
- _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
- restoreLocation: RDEPUBLocation?
- ) {
- paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
- }
-
- func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
- presentationRuntime.applyBookPageMap(
- bookPageMap,
- restoreLocation: restoreLocation
- ) { [weak self] restoreLocation in
- self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
- }
- }
-
- func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
- presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
- }
-
- func applyPendingFullPageMapIfNeeded() {
- presentationRuntime.commitPendingPageMapUpdateIfNeeded()
- }
-
- func finishPagination(restoreLocation: RDEPUBLocation?) {
- paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
- }
-
- func repaginatePreservingCurrentLocation() {
-
- if isSettingsPanelOpen {
- needsFullRepaginationAfterSettingsClose = true
- paginationCoordinator.cancelActiveMetadataParseWork()
- scheduleSettingsPreviewRepagination()
- } else {
- paginationCoordinator.repaginatePreservingCurrentLocation()
- }
- }
-
- private func scheduleSettingsPreviewRepagination() {
- pendingSettingsPreviewWorkItem?.cancel()
- settingsPreviewGeneration += 1
- let previewGeneration = settingsPreviewGeneration
-
- let workItem = DispatchWorkItem { [weak self] in
- guard let self,
- self.isSettingsPanelOpen,
- previewGeneration == self.settingsPreviewGeneration else {
- return
- }
- self.pendingSettingsPreviewWorkItem = nil
- let previewAnchor = self.settingsPreviewAnchor
- ?? self.captureSettingsPreviewAnchor()
- self.chapterRuntimeStore.invalidateAllForSettingsChange()
- self.repaginateCurrentChapterOnly(
- previewGeneration: previewGeneration,
- previewAnchor: previewAnchor
- )
- }
- pendingSettingsPreviewWorkItem = workItem
- DispatchQueue.main.asyncAfter(
- deadline: .now() + settingsPreviewDebounceDelay,
- execute: workItem
- )
- }
-
- private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
- guard let bookPageMap = context.bookPageMap,
- let readerView = context.readerView else { return nil }
-
- let absolutePageIndex = readerView.currentPage
- guard absolutePageIndex >= 0,
- let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
- let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
- let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
- chapter.pages.indices.contains(localPageIndex) else {
- return nil
- }
-
- let page = chapter.pages[localPageIndex]
- let offset = page.contentRange.length > 0
- ? page.contentRange.location
- : page.pageStartOffset
- return SettingsPreviewAnchor(
- spineIndex: spineIndex,
- href: chapter.href,
- offset: offset
- )
- }
-
- private func repaginateCurrentChapterOnly(
- previewGeneration: Int,
- previewAnchor: SettingsPreviewAnchor?
- ) {
- guard let bookPageMap = context.bookPageMap,
- let readerView = context.readerView else { return }
-
- let currentPageNumber = readerView.currentPage + 1
- guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
- return
- }
- let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation()
-
- chapterLoader.loadChapter(
- spineIndex: currentSpineIndex,
- store: chapterRuntimeStore,
- priority: .preview
- ) { [weak self] result in
- guard let self,
- self.isSettingsPanelOpen,
- previewGeneration == self.settingsPreviewGeneration,
- let readerView = self.context.readerView else {
- return
- }
-
- switch result {
- case .success(let chapter):
- let partialMap = self.makePartialPageMap(from: [chapter])
- self.presentationRuntime.applySettingsPreviewPageMap(partialMap)
- readerView.reloadData()
-
- if let targetPage = self.settingsPreviewTargetPage(
- in: chapter,
- for: previewAnchor
- ) {
- readerView.transitionToPage(pageNum: targetPage, animated: false)
- return
- }
-
- if let previewLocation,
- self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
- return
- }
- readerView.transitionToPage(pageNum: 0, animated: false)
-
- case .failure(let error):
- break
- }
- }
- }
-
- private func settingsPreviewTargetPage(
- in chapter: RDEPUBRuntimeChapter,
- for anchor: SettingsPreviewAnchor?
- ) -> Int? {
- guard let anchor,
- anchor.spineIndex == chapter.spineIndex,
- anchor.href == chapter.href,
- !chapter.pages.isEmpty else {
- return nil
- }
-
- if let exactPage = chapter.pages.first(where: { page in
- let lowerBound = page.contentRange.location
- let upperBound = page.contentRange.location + page.contentRange.length
- if page.contentRange.length == 0 {
- return anchor.offset == lowerBound
- }
- return anchor.offset >= lowerBound && anchor.offset < upperBound
- }) {
- return exactPage.pageIndexInChapter
- }
-
- if let nextPage = chapter.pages.first(where: { page in
- page.contentRange.location > anchor.offset
- }) {
- return nextPage.pageIndexInChapter
- }
-
- return max(chapter.pages.count - 1, 0)
- }
-
- func settingsPanelWillAppear() {
- pendingSettingsPreviewWorkItem?.cancel()
- pendingSettingsPreviewWorkItem = nil
- isSettingsPanelOpen = true
- needsFullRepaginationAfterSettingsClose = false
- let fallbackLocation = locationCoordinator.currentVisibleLocation()
- ?? context.persistenceLocation()
- settingsPreviewAnchor = captureSettingsPreviewAnchor()
- settingsRestoreLocation = exactSettingsRestoreLocation(
- anchor: settingsPreviewAnchor,
- fallbackLocation: fallbackLocation
- )
- settingsPreviewGeneration += 1
- }
-
- func settingsPanelDidDisappear() {
- pendingSettingsPreviewWorkItem?.cancel()
- pendingSettingsPreviewWorkItem = nil
- isSettingsPanelOpen = false
- settingsPreviewGeneration += 1
-
- if needsFullRepaginationAfterSettingsClose {
- needsFullRepaginationAfterSettingsClose = false
- paginationCoordinator.repaginatePreservingCurrentLocation(
- preferredRestoreLocation: settingsRestoreLocation
- )
- }
- settingsRestoreLocation = nil
- settingsPreviewAnchor = nil
- }
-
- private func exactSettingsRestoreLocation(
- anchor: SettingsPreviewAnchor?,
- fallbackLocation: RDEPUBLocation?
- ) -> RDEPUBLocation? {
- guard let anchor else { return fallbackLocation }
-
- let textAnchor = RDEPUBTextAnchor(
- fileIndex: anchor.spineIndex,
- row: 0,
- column: 0,
- chapterOffset: anchor.offset,
- fragmentID: fallbackLocation?.fragment
- )
- return RDEPUBLocation(
- bookIdentifier: fallbackLocation?.bookIdentifier ?? context.currentBookIdentifier,
- href: anchor.href,
- progression: fallbackLocation?.progression ?? 0,
- lastProgression: fallbackLocation?.lastProgression,
- fragment: fallbackLocation?.fragment,
- rangeAnchor: RDEPUBTextRangeAnchor(start: textAnchor, end: textAnchor)
- )
- }
-
- func refreshVisibleContentPreservingLocation() {
- paginationCoordinator.refreshVisibleContentPreservingLocation()
- }
-
- func rebuildExternalTextBook() {
- paginationCoordinator.rebuildExternalTextBook()
- }
-
- @discardableResult
- func restoreReadingLocation(
- _ location: RDEPUBLocation,
- animated: Bool = false,
- targetHighlightRangeInfo: String? = nil
- ) -> Bool {
- locationCoordinator.restoreReadingLocation(
- location,
- animated: animated,
- targetHighlightRangeInfo: targetHighlightRangeInfo
- )
- }
-
- func currentVisibleLocation() -> RDEPUBLocation? {
- locationCoordinator.currentVisibleLocation()
- }
-
- func currentViewportSignature() -> RDEPUBViewportSignature? {
- viewportMonitor.currentViewportSignature()
- }
-
- func handleViewportChangeIfNeeded(
- reason: RDEPUBViewportChangeReason,
- viewportSignature: RDEPUBViewportSignature? = nil
- ) {
- viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
- }
-
- @discardableResult
- func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
- chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
- }
-
- @discardableResult
- func prepareOnDemandChapter(
- forAbsolutePageNumber pageNumber: Int,
- allowSynchronousLoad: Bool = true,
- completion: ((Bool) -> Void)? = nil
- ) -> Bool {
- chapterWarmupOrchestrator.prepareOnDemandChapter(
- forAbsolutePageNumber: pageNumber,
- allowSynchronousLoad: allowSynchronousLoad,
- completion: completion
- )
- }
-
- func extendPartialBookPageMapIfNeeded(
- currentPageNumber: Int,
- minimumTrailingPages: Int = 2,
- batchChapterCount: Int = 3
- ) {
- chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
- currentPageNumber: currentPageNumber,
- minimumTrailingPages: minimumTrailingPages,
- batchChapterCount: batchChapterCount
- )
- }
-
- func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
- chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
- anchorSpineIndex: anchorSpineIndex,
- totalSpineCount: totalSpineCount
- )
- }
-
- func clearOnDemandPageModeState() {
- paginationCoordinator.cancelActiveMetadataParseWork()
- chapterRuntimeStore.invalidateAllForSettingsChange()
- context.bookPageMap = nil
- context.pendingPageMapUpdates.removeAll()
- jumpSessionManager.clearSession()
- backgroundPriorityManager.reset()
- backgroundCoverageStore.clearAll()
- chapterWarmupOrchestrator.clear()
- }
-
- func handleMemoryWarning() {
- let currentSpineIndex = locationCoordinator.currentVisibleLocation()
- .flatMap { context.normalizedSpineIndex(for: $0) }
- let activeWindowIndices: Set = if let currentSpineIndex {
- [currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
- } else {
- []
- }
- let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
-
- backgroundCoverageStore.handleMemoryWarning(
- activeWindowSpineIndices: activeWindowIndices,
- protectedSpineIndices: protectedIndices
- )
- chapterRuntimeStore.handleMemoryWarning()
- }
-
- private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
- var builder = RDEPUBBookPageMap.Builder()
- for chapter in chapters {
- builder.add(
- spineIndex: chapter.spineIndex,
- href: chapter.href,
- title: chapter.title,
- pageCount: chapter.pages.count,
- fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
- )
- }
- return builder.build()
- }
-
- private func handleDeferredCFIMapReady(for spineIndex: Int) {
- guard let currentLocation = locationCoordinator.currentVisibleLocation(),
- let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
- visibleSpineIndex == spineIndex else {
- return
- }
- refreshVisibleContentPreservingLocation()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift
deleted file mode 100644
index 1c24f55..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift
+++ /dev/null
@@ -1,419 +0,0 @@
-import Foundation
-
-final class RDEPUBReaderSearchCoordinator {
-
- private unowned let context: RDEPUBReaderContext
-
- private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
-
- private let tokenLock = NSLock()
- private var _currentSearchToken: UUID = UUID()
-
- private var currentSearchToken: UUID {
- get {
- tokenLock.lock()
- defer { tokenLock.unlock() }
- return _currentSearchToken
- }
- set {
- tokenLock.lock()
- _currentSearchToken = newValue
- tokenLock.unlock()
- }
- }
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- private var controller: RDEPUBReaderController? {
- context.controller
- }
-
- func search(keyword: String) {
- guard let controller else { return }
- let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedKeyword.isEmpty else {
- clearSearch()
- return
- }
-
- let token = UUID()
- currentSearchToken = token
-
- let searchEngine = makeSearchEngine()
- let layoutSnapshot = context.makeLayoutSnapshot()
-
- searchQueue.async { [weak self] in
- guard let self, self.currentSearchToken == token else { return }
- let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
-
- DispatchQueue.main.async { [weak self] in
- guard let self, self.currentSearchToken == token else { return }
- guard let controller = self.context.controller else { return }
- controller.searchState = RDEPUBSearchState(
- keyword: normalizedKeyword,
- matches: matches,
- currentMatchIndex: matches.isEmpty ? nil : 0
- )
- self.notifySearchStateChanged()
- if matches.isEmpty {
- controller.refreshVisibleContentPreservingLocation()
- } else {
- _ = self.navigateToCurrentSearchMatch(animated: false)
- }
- }
- }
- }
-
- @discardableResult
- func searchNext() -> Bool {
- advanceSearch(by: 1)
- }
-
- @discardableResult
- func searchPrevious() -> Bool {
- advanceSearch(by: -1)
- }
-
- @discardableResult
- func selectSearchMatch(at index: Int) -> Bool {
- guard let controller else { return false }
- guard var searchState = controller.searchState,
- searchState.matches.indices.contains(index) else {
- return false
- }
- searchState.currentMatchIndex = index
- controller.searchState = searchState
- notifySearchStateChanged()
- return navigateToCurrentSearchMatch(animated: true)
- }
-
- func clearSearch() {
- currentSearchToken = UUID()
- guard let controller else { return }
- controller.searchState = nil
- notifySearchStateChanged()
- controller.refreshVisibleContentPreservingLocation()
- }
-
- func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
- guard let controller else { return nil }
- guard let searchState = controller.searchState,
- let publication = controller.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)
- }
-
- // MARK: - Private
-
- private func makeSearchEngine() -> SearchEngineSnapshot? {
- guard let controller else { return nil }
- if let textBook = controller.textBook, let publication = controller.publication {
- return .textBook(textBook, publication)
- }
- // External text books (e.g. plain .txt) carry a textBook but no
- // publication/parser/bookPageMap; search over the chapter text
- // directly using hrefs from the textBook itself.
- if let textBook = controller.textBook {
- return .externalTextBook(textBook)
- }
- if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
- return .onDemand(publication)
- }
- if let parser = controller.parser, let publication = controller.publication {
- return .html(parser, publication)
- }
- return nil
- }
-
- private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
- guard let engine else { return [] }
- switch engine {
- case .textBook(let textBook, let publication):
- return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
- case .externalTextBook(let textBook):
- return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
- case .onDemand(let publication):
- return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
- case .html(let parser, let publication):
- return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
- }
- }
-
- private enum SearchEngineSnapshot {
- case textBook(RDEPUBTextBook, RDEPUBPublication)
- case externalTextBook(RDEPUBTextBook)
- case onDemand(RDEPUBPublication)
- case html(RDEPUBParser, RDEPUBPublication)
- }
-
- // H-09: Each chapter iteration is wrapped in autoreleasepool to release
- // the chapter's typesetAttributedString memory between iterations.
- private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
- let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedKeyword.isEmpty else {
- return []
- }
-
- let buildableSpineIndices = publication.spine.indices.filter { index in
- let item = publication.spine[index]
- return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
- }
-
- var matches: [RDEPUBSearchMatch] = []
- for spineIndex in buildableSpineIndices {
- let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
- guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
- spineIndex: spineIndex,
- store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
- layoutSnapshot: layoutSnapshot
- ) else {
- return []
- }
-
- let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
- let source = chapter.typesetAttributedString.string as NSString
- let fullLength = source.length
- guard fullLength > 0 else { return [] }
-
- let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
- var localMatches: [RDEPUBSearchMatch] = []
- var localMatchIndex = 0
- var searchRange = NSRange(location: 0, length: fullLength)
-
- while searchRange.length > 0 {
- let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else {
- break
- }
-
- let progressionDenominator = max(fullLength - 1, 1)
- let progression = Double(foundRange.location) / Double(progressionDenominator)
- let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
- localMatches.append(
- RDEPUBSearchMatch(
- href: normalizedHref,
- progression: progression,
- previewText: previewText(in: source, matchRange: foundRange),
- localMatchIndex: localMatchIndex,
- rangeLocation: foundRange.location,
- rangeLength: max(foundRange.length, 1),
- rangeAnchor: rangeAnchor,
- cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
- rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
- )
- )
-
- localMatchIndex += 1
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- if nextLocation >= fullLength {
- break
- }
- searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
- }
- return localMatches
- } // end autoreleasepool
- matches.append(contentsOf: chapterMatches)
- }
-
- return matches
- }
-
- private func makeChapterData(
- from runtimeChapter: RDEPUBRuntimeChapter,
- chapterIndex: Int
- ) -> RDEPUBChapterData {
- let textChapter = RDEPUBTextChapter(
- chapterIndex: chapterIndex,
- spineIndex: runtimeChapter.spineIndex,
- href: runtimeChapter.href,
- title: runtimeChapter.title,
- attributedContent: runtimeChapter.typesetAttributedString,
- fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
- cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
- pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
- pages: runtimeChapter.pages
- )
- return RDEPUBChapterData(
- chapter: textChapter,
- indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
- )
- }
-
- private func previewText(in text: NSString, matchRange: NSRange) -> String {
- let previewRadius = 12
- let start = max(matchRange.location - previewRadius, 0)
- let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
- let range = NSRange(location: start, length: max(end - start, 0))
- return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- private func advanceSearch(by delta: Int) -> Bool {
- guard let controller else { return false }
- guard var searchState = controller.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
- controller.searchState = searchState
- notifySearchStateChanged()
- return navigateToCurrentSearchMatch(animated: true)
- }
-
- private func notifySearchStateChanged() {
- guard let controller else { return }
- let state = controller.searchState
- // 搜索在后台队列执行,提交结果时必须同步刷新搜索面板。
- // 输入事件触发的 updateSearchCount() 发生在结果返回前,只会显示
- // “搜索中”或空状态;如果此处不刷新,正文虽已拿到 searchState
- // 并显示高亮,结果列表仍会一直为空。
- controller.updateSearchCount()
- controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
- controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
- }
-
- private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
- guard let controller else { return false }
- guard let searchMatch = controller.searchState?.currentMatch else {
- controller.refreshVisibleContentPreservingLocation()
- return false
- }
-
- if let targetPageNumber = pageNumber(for: searchMatch),
- controller.readerView.currentPage == targetPageNumber - 1 {
- controller.refreshVisibleContentPreservingLocation()
- return true
- }
-
- let location = RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: searchMatch.href,
- progression: searchMatch.progression,
- lastProgression: searchMatch.progression,
- fragment: nil,
- rangeAnchor: searchMatch.rangeAnchor,
- cfi: searchMatch.cfi,
- rangeCFI: searchMatch.rangeCFI
- )
- return controller.restoreReadingLocation(location, animated: animated)
- }
-
- private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
- guard let controller else { return nil }
- if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
- if let exactPageNumber = exactPageNumber(
- for: searchMatch,
- in: chapterData,
- keyword: controller.searchState?.keyword
- ) {
- return exactPageNumber
- }
-
- if let pageNumber = chapterData.pageNumber(for: searchMatch) {
- return pageNumber
- }
-
- if let rangeLocation = searchMatch.rangeLocation,
- let page = chapterData.page(containing: rangeLocation) {
- return page.absolutePageIndex + 1
- }
- }
-
- let location = RDEPUBLocation(
- bookIdentifier: controller.currentBookIdentifier,
- href: searchMatch.href,
- progression: searchMatch.progression,
- lastProgression: searchMatch.progression,
- fragment: nil,
- rangeAnchor: searchMatch.rangeAnchor,
- cfi: searchMatch.cfi,
- rangeCFI: searchMatch.rangeCFI
- )
-
- if let textBook = controller.textBook {
- if let publication = controller.publication {
- return textBook.pageNumber(
- for: location,
- resolver: publication.resourceResolver,
- bookIdentifier: controller.currentBookIdentifier
- )
- }
- // External text books have no resolver; hrefs match verbatim.
- return textBook.chapterData(for: location.href)?.pageNumber(for: location)
- }
-
- return controller.readingSession?.pageIndex(
- for: location,
- bookIdentifier: controller.currentBookIdentifier
- ).map { $0 + 1 }
- }
-
- private func exactPageNumber(
- for searchMatch: RDEPUBSearchMatch,
- in chapterData: RDEPUBChapterData,
- keyword: String?
- ) -> Int? {
- let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard !normalizedKeyword.isEmpty else { return nil }
-
- let source = chapterData.attributedContent.string as NSString
- let fullLength = source.length
- guard fullLength > 0 else { return nil }
-
- var localMatchIndex = 0
- var searchRange = NSRange(location: 0, length: fullLength)
-
- while searchRange.length > 0 {
- let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
- guard foundRange.location != NSNotFound else { break }
-
- if localMatchIndex == searchMatch.localMatchIndex,
- let page = chapterData.page(containing: foundRange.location) {
- return page.absolutePageIndex + 1
- }
-
- localMatchIndex += 1
- let nextLocation = foundRange.location + max(foundRange.length, 1)
- if nextLocation >= fullLength {
- break
- }
- searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
- }
-
- return nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift
deleted file mode 100644
index 78eabca..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-import Foundation
-
-final class RDEPUBReaderServices {
-
- var dependencies: RDEPUBReaderDependencies
-
- init(dependencies: RDEPUBReaderDependencies) {
- self.dependencies = dependencies
- }
-
- func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer {
- dependencies.makeTextRenderer(configuration.textRenderingEngine)
- }
-
- func makeParser() -> RDEPUBParser {
- dependencies.makeParser()
- }
-
- func makePaginator() -> RDEPUBPaginator {
- dependencies.makePaginator()
- }
-
- func makeTextBookBuilder(
- configuration: RDEPUBReaderConfiguration,
- cache: RDEPUBTextBookCache?,
- layoutConfig: RDEPUBTextLayoutConfig
- ) -> RDEPUBTextBookBuilder {
- dependencies.makeTextBookBuilder(
- resolvedTextRenderer(configuration: configuration),
- cache,
- layoutConfig
- )
- }
-
- func makePlainTextBookBuilder(
- configuration: RDEPUBReaderConfiguration,
- layoutConfig: RDEPUBTextLayoutConfig
- ) -> RDEpubPlainTextBookBuilder {
- dependencies.makePlainTextBookBuilder(
- resolvedTextRenderer(configuration: configuration),
- layoutConfig
- )
- }
-
- func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
- let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
- ?? FileManager.default.temporaryDirectory
- let bookID = (bookIdentifier ?? "default").rd_sha256Hex
- let directory = cachesDirectory
- .appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
- .appendingPathComponent(bookID, isDirectory: true)
- return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift
deleted file mode 100644
index eefa79e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-import UIKit
-
-/// M-01: All properties must be accessed exclusively from the main thread.
-/// This is currently enforced by convention — all verified access paths are main-thread-only.
-/// Adding @MainActor would formalize this but requires iOS 15+ and Swift concurrency throughout.
-/// For now, rely on the audit-verified access patterns and consider @MainActor in a future refactor.
-final class RDEPUBReaderState {
-
- var parser: RDEPUBParser?
-
- var publication: RDEPUBPublication?
-
- var readingSession: RDEPUBReadingSession?
-
- var textBook: RDEPUBTextBook?
-
- var bookPageMap: RDEPUBBookPageMap?
-
- var activeBookmarks: [RDEPUBBookmark] = []
-
- var activeHighlights: [RDEPUBHighlight] = []
-
- var currentBookIdentifier: String?
-
- var paginationToken = UUID()
-
- var searchState: RDEPUBSearchState?
-
- var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
-
- var lastTextPaginationPageSize: CGSize?
-
- var lastMetadataParseWallClockMs: Int = 0
-
- var lastMetadataParseConcurrency: Int = 0
-
- var selectionState: RDEPUBSelectionState = .idle
-
- var isRepaginating: Bool = false
-
- var didStartInitialLoad: Bool = false
-
- var isExternalTextBook: Bool = false
-
- var textFileURL: URL?
-
- let textBookCache = RDEPUBTextBookCache()
-
- var currentSelection: RDEPUBSelection? {
- get { selectionState.selection }
- set {
- if let newValue, !newValue.isEmpty {
- selectionState = .selected(newValue)
- } else {
- selectionState = .idle
- }
- }
- }
-
- var activePages: [EPUBPage] {
- readingSession?.activePages ?? []
- }
-
- var activeChapters: [EPUBChapterInfo] {
- readingSession?.activeChapters ?? []
- }
-
- func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
- readingSession?.setActiveSnapshot(snapshot)
- }
-
- func clearActiveSnapshot() {
- readingSession?.resetRuntimeState()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift
deleted file mode 100644
index 326941a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import Foundation
-
-struct RDEPUBReaderUIState {
-
- let canToggleBookmark: Bool
-
- let hasBookmarkAtCurrentLocation: Bool
-
- let canShowBookmarks: Bool
-
- let canAddHighlight: Bool
-
- let canShowHighlights: Bool
-
- let showsTableOfContents: Bool
-
- let allowsHighlights: Bool
-
- let showsSettingsPanel: Bool
-}
-
-extension RDEPUBReaderUIState {
-
- static let empty = RDEPUBReaderUIState(
- canToggleBookmark: false,
- hasBookmarkAtCurrentLocation: false,
- canShowBookmarks: false,
- canAddHighlight: false,
- canShowHighlights: false,
- showsTableOfContents: true,
- allowsHighlights: true,
- showsSettingsPanel: true
- )
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift
deleted file mode 100644
index 1fb4948..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift
+++ /dev/null
@@ -1,129 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderViewportMonitor {
-
- private unowned let context: RDEPUBReaderContext
-
- private var lastAppliedViewportSignature: RDEPUBViewportSignature?
-
- private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
-
- private var pendingPresentationRestoreLocation: RDEPUBLocation?
-
- private var isWaitingForViewportTransitionCompletion = false
-
- init(context: RDEPUBReaderContext) {
- self.context = context
- }
-
- private var controller: RDEPUBReaderController? {
- context.controller
- }
-
- func viewDidLayoutSubviews() {
- guard let controller else { return }
- guard let viewportSignature = currentViewportSignature() else { return }
-
- if !controller.didStartInitialLoad {
- lastAppliedViewportSignature = viewportSignature
- controller.startInitialLoadIfNeeded()
- return
- }
-
- guard controller.publication != nil || controller.isExternalTextBook else {
- lastAppliedViewportSignature = viewportSignature
- return
- }
-
- guard !isWaitingForViewportTransitionCompletion else {
- return
- }
-
- handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
- }
-
- func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
- guard let controller else { return }
- guard controller.didStartInitialLoad else { return }
- pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
- isWaitingForViewportTransitionCompletion = true
-
- coordinator.animate(alongsideTransition: nil) { [weak self] _ in
- guard let self, let controller = self.controller else { return }
- self.isWaitingForViewportTransitionCompletion = false
- controller.view.layoutIfNeeded()
- self.handleViewportChangeIfNeeded(reason: .orientationTransition)
- }
- }
-
- func resetForReload() {
- lastAppliedViewportSignature = currentViewportSignature()
- pendingViewportChangeReason = nil
- pendingPresentationRestoreLocation = nil
- isWaitingForViewportTransitionCompletion = false
- }
-
- func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
- defer { pendingPresentationRestoreLocation = nil }
- return pendingPresentationRestoreLocation
- }
-
- func capturePendingPresentationRestoreLocation() {
- guard let controller else { return }
- pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
- }
-
- func processPendingChangeAfterPagination() {
- guard let pendingReason = pendingViewportChangeReason else { return }
- pendingViewportChangeReason = nil
- DispatchQueue.main.async { [weak self] in
- self?.handleViewportChangeIfNeeded(reason: pendingReason)
- }
- }
-
- func currentViewportSignature() -> RDEPUBViewportSignature? {
- guard let controller else { return nil }
- let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
- guard containerSize.width > 0, containerSize.height > 0 else { return nil }
- let insets = controller.view.safeAreaInsets
- return RDEPUBViewportSignature(
- width: containerSize.width,
- height: containerSize.height,
- safeTop: insets.top,
- safeLeft: insets.left,
- safeBottom: insets.bottom,
- safeRight: insets.right
- )
- }
-
- func handleViewportChangeIfNeeded(
- reason: RDEPUBViewportChangeReason,
- viewportSignature: RDEPUBViewportSignature? = nil
- ) {
- guard let controller else { return }
- guard controller.didStartInitialLoad,
- let signature = viewportSignature ?? currentViewportSignature() else {
- return
- }
-
- if controller.isRepaginating {
- pendingViewportChangeReason = reason
- return
- }
-
- if let lastAppliedViewportSignature,
- !signature.differsSignificantly(from: lastAppliedViewportSignature) {
- return
- }
-
- lastAppliedViewportSignature = signature
-
- if controller.isExternalTextBook {
- controller.rebuildExternalTextBook()
- return
- }
-
- guard controller.publication != nil else { return }
- controller.repaginatePreservingCurrentLocation()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift
deleted file mode 100644
index 0949414..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-
-import Foundation
-
-enum RDEPUBSelectionState: Equatable {
-
- case idle
-
- case selecting(anchor: Int)
-
- case selected(RDEPUBSelection)
-
- case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
-
- var hasSelection: Bool {
- switch self {
- case .idle:
- return false
- case .selecting, .selected, .committingAction:
- return true
- }
- }
-
- var selection: RDEPUBSelection? {
- switch self {
- case .idle, .selecting:
- return nil
- case .selected(let selection), .committingAction(let selection, _):
- return selection
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift
deleted file mode 100644
index 25203fd..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift
+++ /dev/null
@@ -1,215 +0,0 @@
-import UIKit
-
-public enum RDEPUBTextRenderingEngine: Equatable {
-
- case dtCoreText
-}
-
-public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
-
- case system
-
- case serif
-
- case rounded
-
- case monospaced
-
- public var displayName: String {
- switch self {
- case .system:
- return "系统"
- case .serif:
- return "宋体"
- case .rounded:
- return "圆体"
- case .monospaced:
- return "等宽"
- }
- }
-
- public func font(ofSize size: CGFloat) -> UIFont {
- switch self {
- case .system:
- return UIFont.systemFont(ofSize: size)
- case .serif:
- let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
- .withDesign(.serif) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
- return UIFont(descriptor: descriptor, size: size)
- case .rounded:
- let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
- .withDesign(.rounded) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
- return UIFont(descriptor: descriptor, size: size)
- case .monospaced:
- return UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
- }
- }
-}
-
-/// 试读策略:开启后,阅读器在最后一个可读页之后追加一页“试读墙”(UI 由宿主经 delegate 提供)。
-public struct RDEPUBReaderTrialPolicy: Equatable {
-
- /// 可读章节数(可读 spine 为 0..
-
- public var requiresExternalLinkConfirmation: Bool
-
- public var allowsInspectableWebViews: Bool
-
- public var enablesVerboseWebViewLogging: Bool
-
- /// 试读策略;nil = 非试读(全书可读、无试读墙)。
- public var trialPolicy: RDEPUBReaderTrialPolicy?
-
- public init(
- fontSize: CGFloat = 15,
- lineHeightMultiple: CGFloat = 1.6,
- fontChoice: RDEPUBReaderFontChoice = .system,
- numberOfColumns: Int = 1,
- columnGap: CGFloat = 20,
- displayType: RDEpubReaderView.DisplayType = .pageCurl,
- landscapeDualPageEnabled: Bool = true,
- showsTableOfContents: Bool = true,
- allowsHighlights: Bool = true,
- showsSettingsPanel: Bool = true,
- reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
- fixedContentInset: UIEdgeInsets = .zero,
- theme: RDEPUBReaderTheme = .light,
- darkImageAdjustmentEnabled: Bool = true,
- darkImageBlendRatio: CGFloat = 0.15,
- fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
- fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
- textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
- onDemandChapterWindowSize: Int = 3,
- metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
- jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default,
- allowedExternalURLSchemes: Set = ["https"],
- requiresExternalLinkConfirmation: Bool = true,
- allowsInspectableWebViews: Bool = false,
- enablesVerboseWebViewLogging: Bool = false,
- trialPolicy: RDEPUBReaderTrialPolicy? = nil
- ) {
- self.fontSize = fontSize
- self.lineHeightMultiple = lineHeightMultiple
- self.fontChoice = fontChoice
- self.numberOfColumns = max(1, numberOfColumns)
- self.columnGap = max(0, columnGap)
- self.displayType = displayType
- self.landscapeDualPageEnabled = landscapeDualPageEnabled
- self.showsTableOfContents = showsTableOfContents
- self.allowsHighlights = allowsHighlights
- self.showsSettingsPanel = showsSettingsPanel
- self.reflowableContentInsets = reflowableContentInsets
- self.fixedContentInset = fixedContentInset
- self.theme = theme
- self.darkImageAdjustmentEnabled = darkImageAdjustmentEnabled
- self.darkImageBlendRatio = max(0, min(0.35, darkImageBlendRatio))
- self.fixedLayoutFit = fixedLayoutFit
- self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
- self.textRenderingEngine = textRenderingEngine
- self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
- self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
- self.jumpSessionPolicy = jumpSessionPolicy
- self.allowedExternalURLSchemes = allowedExternalURLSchemes
- self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
- self.allowsInspectableWebViews = allowsInspectableWebViews
- self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
- self.trialPolicy = trialPolicy
- }
-
- public static let `default` = RDEPUBReaderConfiguration()
-}
-
-extension RDEPUBReaderConfiguration {
-
- static func normalizedChapterWindowSize(_ size: Int) -> Int {
- let clamped = max(3, min(15, size))
- return clamped % 2 == 0 ? clamped + 1 : clamped
- }
-
- var chapterWindowRadius: Int {
- onDemandChapterWindowSize / 2
- }
-}
-
-extension RDEPUBReaderConfiguration {
-
- func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
- // Use the larger of reflowableContentInsets and safeAreaInsets for each edge
- // to prevent content from being hidden under Dynamic Island / home indicator.
- // Falls back to the key-window safe area when the caller has no laid-out view.
- let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
- let safeInsets = UIEdgeInsets(
- top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
- left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
- bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
- right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
- )
- return RDEPUBPreferences(
- fontSize: fontSize,
- lineHeightMultiple: lineHeightMultiple,
- reflowableContentInsets: safeInsets,
- fixedContentInset: fixedContentInset,
- numberOfColumns: numberOfColumns,
- columnGap: columnGap,
- themeBackgroundColor: theme.themeBackgroundColorCSS,
- themeTextColor: theme.themeTextColorCSS,
- fixedBackgroundColor: theme.themeBackgroundColorCSS,
- fixedLayoutFit: fixedLayoutFit,
- fixedLayoutSpreadMode: fixedLayoutSpreadMode
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift
deleted file mode 100644
index ef8ac29..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift
+++ /dev/null
@@ -1,160 +0,0 @@
-import UIKit
-
-public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
-
- case pageCurl
-
- case horizontalScroll
-
- case verticalScroll
-
- case horizontalCoverScroll
-
- init(displayType: RDEpubReaderView.DisplayType) {
- switch displayType {
- case .pageCurl:
- self = .pageCurl
- case .horizontalScroll:
- self = .horizontalScroll
- case .verticalScroll:
- self = .verticalScroll
- }
- }
-
- var displayType: RDEpubReaderView.DisplayType {
- switch self {
- case .pageCurl:
- return .pageCurl
- case .horizontalScroll, .horizontalCoverScroll:
- return .horizontalScroll
- case .verticalScroll:
- return .verticalScroll
- }
- }
-}
-
-public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
-
- case light
-
- case yellow
-
- case green
-
- case pink
-
- case blue
-
- case dark
-
- public var theme: RDEPUBReaderTheme {
- switch self {
- case .light:
- return .light
- case .yellow:
- return .yellow
- case .green:
- return .green
- case .pink:
- return .pink
- case .blue:
- return .blue
- case .dark:
- return .dark
- }
- }
-
- public init?(theme: RDEPUBReaderTheme) {
- switch theme {
- case .light:
- self = .light
- case .yellow:
- self = .yellow
- case .green:
- self = .green
- case .pink:
- self = .pink
- case .blue:
- self = .blue
- case .dark:
- self = .dark
- default:
- return nil
- }
- }
-}
-
-public struct RDEPUBReaderSettings: Codable, Equatable {
-
- public var brightness: CGFloat?
-
- public var fontSize: CGFloat?
-
- public var fontChoice: RDEPUBReaderFontChoice?
-
- public var lineHeightMultiple: CGFloat?
-
- public var numberOfColumns: Int?
-
- public var displayMode: RDEPUBReaderDisplayMode?
-
- public var themePreset: RDEPUBReaderThemePreset?
-
- public init(
- brightness: CGFloat? = nil,
- fontSize: CGFloat? = nil,
- fontChoice: RDEPUBReaderFontChoice? = nil,
- lineHeightMultiple: CGFloat? = nil,
- numberOfColumns: Int? = nil,
- displayMode: RDEPUBReaderDisplayMode? = nil,
- themePreset: RDEPUBReaderThemePreset? = nil
- ) {
- self.brightness = brightness
- self.fontSize = fontSize
- self.fontChoice = fontChoice
- self.lineHeightMultiple = lineHeightMultiple
- self.numberOfColumns = numberOfColumns
- self.displayMode = displayMode
- self.themePreset = themePreset
- }
-
- public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
- var resolvedConfiguration = configuration
-
- if let fontSize {
- resolvedConfiguration.fontSize = fontSize
- }
- if let fontChoice {
- resolvedConfiguration.fontChoice = fontChoice
- }
- if let lineHeightMultiple {
- resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
- }
- if let numberOfColumns {
- resolvedConfiguration.numberOfColumns = max(1, numberOfColumns)
- }
- if let displayMode {
- resolvedConfiguration.displayType = displayMode.displayType
- }
- if let themePreset {
- resolvedConfiguration.theme = themePreset.theme
- }
-
- return resolvedConfiguration
- }
-
- public static func capture(
- configuration: RDEPUBReaderConfiguration,
- brightness: CGFloat
- ) -> RDEPUBReaderSettings {
- RDEPUBReaderSettings(
- brightness: max(0, min(1, brightness)),
- fontSize: configuration.fontSize,
- fontChoice: configuration.fontChoice,
- lineHeightMultiple: configuration.lineHeightMultiple,
- numberOfColumns: configuration.numberOfColumns,
- displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
- themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift
deleted file mode 100644
index 871d11d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift
+++ /dev/null
@@ -1,368 +0,0 @@
-import UIKit
-
-final class RDEPUBReaderSettingsViewController: UIViewController {
-
- var onBrightnessChange: ((CGFloat) -> Void)?
-
- var onFontSizeChange: ((CGFloat) -> Void)?
-
- var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
-
- var onLineHeightChange: ((CGFloat) -> Void)?
-
- var onColumnCountChange: ((Int) -> Void)?
-
- var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
-
- var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
-
- var onDismiss: (() -> Void)?
-
- private enum ThemePreset: Int, CaseIterable {
- case light
- case yellow
- case green
- case pink
- case blue
- case dark
-
- var title: String {
- switch self {
- case .light: return "浅色"
- case .yellow: return "米黄"
- case .green: return "青绿"
- case .pink: return "粉色"
- case .blue: return "蓝灰"
- case .dark: return "夜间"
- }
- }
-
- var theme: RDEPUBReaderTheme {
- switch self {
- case .light: return .light
- case .yellow: return .yellow
- case .green: return .green
- case .pink: return .pink
- case .blue: return .blue
- case .dark: return .dark
- }
- }
- }
-
- private let scrollView = UIScrollView()
-
- private let contentStack: UIStackView = {
- let stackView = UIStackView()
- stackView.axis = .vertical
- stackView.spacing = 20
- return stackView
- }()
-
- private let brightnessSlider = UISlider()
-
- private let fontValueLabel = UILabel()
-
- private let decreaseFontButton = UIButton(type: .system)
-
- private let increaseFontButton = UIButton(type: .system)
-
- private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
-
- private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
-
- private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
-
- private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
-
- private let themeStackView: UIStackView = {
- let stackView = UIStackView()
- stackView.axis = .horizontal
- stackView.distribution = .fillEqually
- stackView.spacing = 12
- return stackView
- }()
-
- private var themeButtons: [UIButton] = []
-
- private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
-
- private var currentConfiguration: RDEPUBReaderConfiguration
-
- init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
- self.currentConfiguration = configuration
- super.init(nibName: nil, bundle: nil)
- brightnessSlider.value = Float(brightness)
- title = "样式设置"
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- setupNavigationItems()
- setupViews()
- syncControls()
- applyTheme(currentConfiguration.theme)
- }
-
- private func setupNavigationItems() {
- let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
- doneItem.accessibilityIdentifier = "epub.reader.settings.done"
- navigationItem.rightBarButtonItem = doneItem
- }
-
- private func setupViews() {
- view.addSubview(scrollView)
- scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.addSubview(contentStack)
- contentStack.translatesAutoresizingMaskIntoConstraints = false
-
- NSLayoutConstraint.activate([
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-
- contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
- contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
- contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
- contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
- contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
- ])
-
- brightnessSlider.minimumValue = 0
- brightnessSlider.maximumValue = 1
- brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
- brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
-
- fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
- fontValueLabel.textAlignment = .center
- fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
- fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
-
- configureFontButton(decreaseFontButton, title: "A-")
- configureFontButton(increaseFontButton, title: "A+")
- decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
- increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
- decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
- increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
-
- lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
- fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
- columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
- displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
- fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
- lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
- columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
- displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
-
- ThemePreset.allCases.forEach { preset in
- let button = UIButton(type: .system)
- button.tag = preset.rawValue
- button.layer.cornerRadius = 18
- button.layer.borderWidth = 1.5
- button.backgroundColor = preset.theme.contentBackgroundColor
- button.accessibilityLabel = preset.title
- button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
- button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
- themeButtons.append(button)
- themeStackView.addArrangedSubview(button)
- NSLayoutConstraint.activate([
- button.heightAnchor.constraint(equalToConstant: 36)
- ])
- }
-
- contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
- contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
- contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
- contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
- contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
- contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
- contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
- }
-
- private func makeSection(title: String, content: UIView) -> UIView {
- let container = UIStackView()
- container.axis = .vertical
- container.spacing = 10
-
- let titleLabel = UILabel()
- titleLabel.text = title
- titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
-
- content.translatesAutoresizingMaskIntoConstraints = false
- container.addArrangedSubview(titleLabel)
- container.addArrangedSubview(content)
- return container
- }
-
- private func makeFontSizeRow() -> UIView {
- let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
- stackView.axis = .horizontal
- stackView.alignment = .center
- stackView.distribution = .fill
- stackView.spacing = 12
-
- NSLayoutConstraint.activate([
- decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
- increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
- decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
- increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
- ])
- return stackView
- }
-
- private func configureFontButton(_ button: UIButton, title: String) {
- button.setTitle(title, for: .normal)
- button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
- button.layer.cornerRadius = 18
- button.layer.borderWidth = 1
- }
-
- private func syncControls() {
- fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
- fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
-
- let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
- lineHeightControl.selectedSegmentIndex = lineHeightIndex
- columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
-
- switch currentConfiguration.displayType {
- case .pageCurl:
- displayTypeControl.selectedSegmentIndex = 0
- case .horizontalScroll:
- displayTypeControl.selectedSegmentIndex = 1
- case .verticalScroll:
- displayTypeControl.selectedSegmentIndex = 2
- }
-
- let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
- updateThemeSelection(selectedPreset)
- updateControlAccessibilityValues()
- }
-
- private func applyTheme(_ theme: RDEPUBReaderTheme) {
- view.backgroundColor = theme.contentBackgroundColor
- scrollView.backgroundColor = theme.contentBackgroundColor
- contentStack.arrangedSubviews
- .compactMap { $0 as? UIStackView }
- .flatMap { $0.arrangedSubviews }
- .compactMap { $0 as? UILabel }
- .forEach { $0.textColor = theme.contentTextColor }
-
- fontValueLabel.textColor = theme.contentTextColor
- [decreaseFontButton, increaseFontButton].forEach { button in
- button.setTitleColor(theme.toolControlTextColor, for: .normal)
- button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
- button.backgroundColor = theme.toolBackgroundColor
- }
-
- [fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
- control.backgroundColor = theme.toolBackgroundColor
- if #available(iOS 13.0, *) {
- control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
- } else {
- control.tintColor = theme.toolControlTextColor
- }
- control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
- control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
- }
-
- navigationController?.navigationBar.tintColor = theme.toolControlTextColor
- navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
- navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
- updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
- }
-
- private func updateThemeSelection(_ preset: ThemePreset) {
- themeButtons.forEach { button in
- let isSelected = button.tag == preset.rawValue
- button.layer.borderWidth = isSelected ? 2 : 1
- button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
- button.accessibilityValue = isSelected ? "selected" : "unselected"
- }
- }
-
- private func updateControlAccessibilityValues() {
- fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
- lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
- columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
- displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
- }
-
- @objc private func doneAction() {
- dismiss(animated: true) { [weak self] in
- self?.onDismiss?()
- }
- }
-
- @objc private func brightnessChanged(_ slider: UISlider) {
- onBrightnessChange?(CGFloat(slider.value))
- }
-
- @objc private func decreaseFontAction() {
- let nextValue = max(12, currentConfiguration.fontSize - 1)
- guard nextValue != currentConfiguration.fontSize else { return }
- currentConfiguration.fontSize = nextValue
- fontValueLabel.text = String(Int(nextValue.rounded()))
- onFontSizeChange?(nextValue)
- }
-
- @objc private func increaseFontAction() {
- let nextValue = min(36, currentConfiguration.fontSize + 1)
- guard nextValue != currentConfiguration.fontSize else { return }
- currentConfiguration.fontSize = nextValue
- fontValueLabel.text = String(Int(nextValue.rounded()))
- onFontSizeChange?(nextValue)
- }
-
- @objc private func fontChoiceChanged(_ control: UISegmentedControl) {
- let choices = RDEPUBReaderFontChoice.allCases
- let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
- let choice = choices[index]
- guard choice != currentConfiguration.fontChoice else { return }
- currentConfiguration.fontChoice = choice
- updateControlAccessibilityValues()
- onFontChoiceChange?(choice)
- }
-
- @objc private func lineHeightChanged(_ control: UISegmentedControl) {
- let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
- let value = lineHeightValues[index]
- currentConfiguration.lineHeightMultiple = value
- updateControlAccessibilityValues()
- onLineHeightChange?(value)
- }
-
- @objc private func columnCountChanged(_ control: UISegmentedControl) {
- let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
- currentConfiguration.numberOfColumns = numberOfColumns
- updateControlAccessibilityValues()
- onColumnCountChange?(numberOfColumns)
- }
-
- @objc private func displayTypeChanged(_ control: UISegmentedControl) {
- let displayType: RDEpubReaderView.DisplayType
- switch control.selectedSegmentIndex {
- case 1:
- displayType = .horizontalScroll
- case 2:
- displayType = .verticalScroll
- default:
- displayType = .pageCurl
- }
- currentConfiguration.displayType = displayType
- updateControlAccessibilityValues()
- onDisplayTypeChange?(displayType)
- }
-
- @objc private func themeButtonAction(_ sender: UIButton) {
- guard let preset = ThemePreset(rawValue: sender.tag) else { return }
- currentConfiguration.theme = preset.theme
- applyTheme(preset.theme)
- onThemeChange?(preset.theme)
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift
deleted file mode 100644
index 9a3a6d7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift
+++ /dev/null
@@ -1,103 +0,0 @@
-import UIKit
-
-public struct RDEPUBReaderTheme: Equatable {
-
- public var contentBackgroundColor: UIColor
-
- public var contentTextColor: UIColor
-
- public var toolBackgroundColor: UIColor
-
- public var toolControlTextColor: UIColor
-
- public var toolControlBorderUnselectColor: UIColor
-
- public var toolLineColor: UIColor
-
- public init(
- contentBackgroundColor: UIColor,
- contentTextColor: UIColor,
- toolBackgroundColor: UIColor,
- toolControlTextColor: UIColor,
- toolControlBorderUnselectColor: UIColor,
- toolLineColor: UIColor
- ) {
- self.contentBackgroundColor = contentBackgroundColor
- self.contentTextColor = contentTextColor
- self.toolBackgroundColor = toolBackgroundColor
- self.toolControlTextColor = toolControlTextColor
- self.toolControlBorderUnselectColor = toolControlBorderUnselectColor
- self.toolLineColor = toolLineColor
- }
-
- public static let light = RDEPUBReaderTheme(
- contentBackgroundColor: .white,
- contentTextColor: .black,
- toolBackgroundColor: .white,
- toolControlTextColor: .black,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-
- public static let dark = RDEPUBReaderTheme(
- contentBackgroundColor: .black,
- contentTextColor: .white,
- toolBackgroundColor: .black,
- toolControlTextColor: .white,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-
- public static let yellow = RDEPUBReaderTheme(
- contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
- contentTextColor: .black,
- toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
- toolControlTextColor: .black,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-
- public static let green = RDEPUBReaderTheme(
- contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
- contentTextColor: .black,
- toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
- toolControlTextColor: .black,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-
- public static let pink = RDEPUBReaderTheme(
- contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
- contentTextColor: .black,
- toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
- toolControlTextColor: .black,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-
- public static let blue = RDEPUBReaderTheme(
- contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
- contentTextColor: .black,
- toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
- toolControlTextColor: .black,
- toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
- toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
- )
-}
-
-extension RDEPUBReaderTheme {
-
- /// Background color for the full-screen image viewer.
- /// Always near-black regardless of theme, since the image is the focus.
- var imageViewerBackgroundColor: UIColor {
- UIColor(white: 0.0, alpha: 0.95)
- }
-
- var themeBackgroundColorCSS: String {
- contentBackgroundColor.rd_cssString
- }
-
- var themeTextColorCSS: String {
- contentTextColor.rd_cssString
- }
-}
\ No newline at end of file
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift
deleted file mode 100644
index fddc71a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-import Foundation
-import CryptoKit
-
-extension String {
-
- /// trim 后空字符串返回 nil
- var rd_nilIfEmpty: String? {
- let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
- }
-
- /// SHA256 hex 摘要
- var rd_sha256Hex: String {
- let digest = SHA256.hash(data: Data(self.utf8))
- return digest.map { String(format: "%02x", $0) }.joined()
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift
deleted file mode 100644
index d4ba110..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift
+++ /dev/null
@@ -1,114 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-/// Chapter-level shared display content and layouter
-/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
-///
-/// One entry holds the chapter-length display string (theme text color and
-/// dark-image adjustment injected once over the full chapter at build time)
-/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
-/// the shared string and request per-page layout frames; they must never
-/// mutate the shared string — the framesetter and any live layout frame hold
-/// it. Page-level decoration (highlights, search, selection) is drawn by the
-/// overlay views instead.
-///
-/// Main-thread only.
-final class RDEPUBChapterDisplayContentCache {
-
-#if canImport(DTCoreText)
-
- struct Entry {
- let content: NSAttributedString
- let layouter: DTCoreTextLayouter?
- fileprivate let signature: Signature
- }
-
- fileprivate struct Signature: Equatable {
- let chapterContentID: ObjectIdentifier
- let contentLength: Int
- let textColor: UIColor
- let backgroundColor: UIColor
- let darkImageAdjustmentEnabled: Bool
- let darkImageBlendRatio: CGFloat
-
- init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
- chapterContentID = ObjectIdentifier(page.chapterContent)
- contentLength = page.chapterContent.length
- textColor = configuration.theme.contentTextColor
- backgroundColor = configuration.theme.contentBackgroundColor
- darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
- darkImageBlendRatio = configuration.darkImageBlendRatio
- }
- }
-
- /// Current chapter plus one adjacent chapter during page-curl/scroll
- /// transitions across a chapter boundary.
- private static let capacity = 2
-
- private var entries: [String: Entry] = [:]
-
- private var accessOrder: [String] = []
-
- func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
- dispatchPrecondition(condition: .onQueue(.main))
- let signature = Signature(page: page, configuration: configuration)
- if let cached = entries[page.href], cached.signature == signature {
- touch(page.href)
- return cached
- }
- let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
- entries[page.href] = entry
- touch(page.href)
- evictIfNeeded()
- RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
- return entry
- }
-
- func removeAll() {
- entries.removeAll()
- accessOrder.removeAll()
- }
-
- private func touch(_ href: String) {
- accessOrder.removeAll { $0 == href }
- accessOrder.append(href)
- }
-
- private func evictIfNeeded() {
- while accessOrder.count > Self.capacity {
- let evicted = accessOrder.removeFirst()
- entries.removeValue(forKey: evicted)
- }
- }
-
- private static func buildEntry(
- page: RDEPUBTextPage,
- configuration: RDEPUBReaderConfiguration,
- signature: Signature
- ) -> Entry {
- let content = NSMutableAttributedString(attributedString: page.chapterContent)
- let fullRange = NSRange(location: 0, length: content.length)
- _ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
- content,
- in: fullRange,
- configuration: configuration
- )
- content.addAttribute(
- .foregroundColor,
- value: configuration.theme.contentTextColor,
- range: fullRange
- )
- let layouter = DTCoreTextLayouter(attributedString: content)
- layouter?.shouldCacheLayoutFrames = false
- return Entry(content: content, layouter: layouter, signature: signature)
- }
-
-#else
-
- func removeAll() {}
-
-#endif
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift
deleted file mode 100644
index acc3534..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift
+++ /dev/null
@@ -1,107 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-enum RDEPUBDarkImageAdjuster {
-
- private static let imageCache: NSCache = {
- let cache = NSCache()
- cache.countLimit = 100
- cache.totalCostLimit = 52_428_800 // 50 MB
- return cache
- }()
-
- private static func imageCost(of image: UIImage) -> Int {
- let scale = image.scale
- let width = Int(image.size.width * scale)
- let height = Int(image.size.height * scale)
- // 4 bytes per pixel (RGBA)
- return width * height * 4
- }
-
-#if canImport(DTCoreText)
-
- static func adjustIfNeeded(
- _ content: NSMutableAttributedString,
- in range: NSRange? = nil,
- configuration: RDEPUBReaderConfiguration
- ) -> NSMutableAttributedString {
- guard configuration.darkImageAdjustmentEnabled,
- configuration.darkImageBlendRatio > 0,
- configuration.theme.contentBackgroundColor.rd_isDarkBackground else {
- return content
- }
-
- let fullRange = NSRange(location: 0, length: content.length)
- let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
- content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
- guard let attachment = value as? DTImageTextAttachment,
- !isCoverAttachment(attachment),
- let image = attachment.image,
- shouldAdjust(image) else { return }
-
- let adjustedAttachment = DTImageTextAttachment()
- adjustedAttachment.image = adjustedImage(
- image,
- backgroundColor: configuration.theme.contentBackgroundColor,
- blendRatio: configuration.darkImageBlendRatio,
- cacheKey: cacheKey(for: attachment, image: image, configuration: configuration)
- )
- adjustedAttachment.originalSize = attachment.originalSize
- adjustedAttachment.displaySize = attachment.displaySize
- adjustedAttachment.verticalAlignment = attachment.verticalAlignment
- adjustedAttachment.contentURL = attachment.contentURL
- adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
- adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
- adjustedAttachment.attributes = attachment.attributes
- content.addAttribute(.attachment, value: adjustedAttachment, range: range)
- }
- return content
- }
-
- private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
- let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
- let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
- ?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
- return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
- }
-
- private static func shouldAdjust(_ image: UIImage) -> Bool {
- image.size.width >= 80 && image.size.height >= 80
- }
-
- private static func cacheKey(
- for attachment: DTImageTextAttachment,
- image: UIImage,
- configuration: RDEPUBReaderConfiguration
- ) -> NSString {
- let source = attachment.contentURL?.absoluteString
- ?? "\(Unmanaged.passUnretained(image).toOpaque())"
- return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
- }
-
- private static func adjustedImage(
- _ image: UIImage,
- backgroundColor: UIColor,
- blendRatio: CGFloat,
- cacheKey: NSString
- ) -> UIImage {
- if let cached = imageCache.object(forKey: cacheKey) { return cached }
- let format = UIGraphicsImageRendererFormat()
- format.scale = image.scale
- format.opaque = false
- let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
- let adjusted = renderer.image { context in
- image.draw(in: CGRect(origin: .zero, size: image.size))
- backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
- context.cgContext.setBlendMode(.sourceAtop)
- context.fill(CGRect(origin: .zero, size: image.size))
- }
- imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
- return adjusted
- }
-
-#endif
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift
deleted file mode 100644
index e0e3689..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift
+++ /dev/null
@@ -1,215 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-final class RDEPUBPageInteractionController {
-
- var snapshot: RDEPUBPageLayoutSnapshot?
-
- private var dtLayoutFrame: DTCoreTextLayoutFrame?
-
-#if canImport(DTCoreText)
-
- func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
- dtLayoutFrame = layoutFrame
- if let layoutFrame, let page {
- snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page)
- } else {
- snapshot = nil
- }
- }
-#endif
-
- func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
- let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
- return characterIndex(at: localPoint)
- }
-
- func characterIndex(at point: CGPoint) -> Int? {
- guard let snapshot else { return nil }
-
- for attachment in snapshot.attachments {
- if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
- return attachment.stringRange.location
- }
- }
-
- guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil }
-
-#if canImport(DTCoreText)
- guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
- let relativePoint = CGPoint(
- x: point.x - line.baselineOrigin.x,
- y: point.y - line.baselineOrigin.y
- )
- // The layout frame is built in chapter context, so DTCoreText string
- // indices are chapter-absolute already.
- let idx = dtLine.stringIndex(forPosition: relativePoint)
- guard idx != NSNotFound, idx >= 0 else { return nil }
- return normalizedIndex(
- idx,
- lineRange: line.stringRange,
- pageRange: snapshot.pageContentRange
- )
-#else
- return nil
-#endif
- }
-
- func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
- guard let start = characterIndex(at: startPoint),
- let end = characterIndex(at: endPoint) else { return nil }
- let lower = min(start, end)
- let upper = max(start, end)
- return NSRange(location: lower, length: max(upper - lower, 1))
- }
-
- func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
- guard let snapshot else { return [] }
- var rects: [CGRect] = []
-
- for line in snapshot.lines {
- let overlap = NSIntersectionRange(line.stringRange, absoluteRange)
- guard overlap.length > 0 else { continue }
-
-#if canImport(DTCoreText)
- guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
- let startX = dtLine.offset(forStringIndex: overlap.location)
- let endIdx = overlap.location + overlap.length
- let endX = dtLine.offset(forStringIndex: endIdx)
-#else
- let startX: CGFloat = 0
- let endX: CGFloat = line.frame.width
-#endif
-
- let rect = CGRect(
- x: line.baselineOrigin.x + startX,
- y: line.baselineOrigin.y - line.ascent,
- width: max(endX - startX, 2),
- height: line.ascent + line.descent
- )
- rects.append(rect)
- }
-
- return mergeAdjacentRects(rects)
- }
-
- func firstRect(for absoluteRange: NSRange) -> CGRect? {
- selectionRects(for: absoluteRange).first
- }
-
- func lastRect(for absoluteRange: NSRange) -> CGRect? {
- selectionRects(for: absoluteRange).last
- }
-
- func boundingRect(for absoluteRange: NSRange) -> CGRect? {
- let rects = selectionRects(for: absoluteRange)
- guard var rect = rects.first else { return nil }
- for next in rects.dropFirst() {
- rect = rect.union(next)
- }
- return rect
- }
-
- func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? {
- guard let first = firstRect(for: absoluteRange),
- let last = lastRect(for: absoluteRange) else {
- return boundingRect(for: absoluteRange)
- }
-
- let minX = min(first.minX, last.minX)
- let maxX = max(first.maxX, last.maxX)
- let minY = min(first.minY, last.minY)
- let maxY = max(first.maxY, last.maxY)
- return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
- }
-
- func caretRect(at index: Int) -> CGRect? {
- guard let snapshot else { return nil }
- guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
- return nil
- }
-
-#if canImport(DTCoreText)
- guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
- let offsetX = dtLine.offset(forStringIndex: index)
-#else
- let offsetX: CGFloat = 0
-#endif
-
- return CGRect(
- x: line.baselineOrigin.x + offsetX - 1,
- y: line.baselineOrigin.y - line.ascent,
- width: 2,
- height: line.ascent + line.descent
- )
- }
-
- private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
- var bestLine: RDEPUBPageLine?
- var bestDistance: CGFloat = .greatestFiniteMagnitude
-
- for line in lines {
- let lineBottom = line.baselineOrigin.y + line.descent
- let lineTop = line.baselineOrigin.y - line.ascent
- if point.y >= lineTop && point.y <= lineBottom {
- return line
- }
- let lineMidY = (lineTop + lineBottom) / 2
- let distance = abs(point.y - lineMidY)
-
- if distance < bestDistance {
- bestDistance = distance
- bestLine = line
- }
- }
-
- return bestLine
- }
-
- private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int {
- var result = idx
- if result < lineRange.location {
- result = lineRange.location
- }
- let lineEnd = lineRange.location + lineRange.length
- if result >= lineEnd {
- result = max(lineEnd - 1, lineRange.location)
- }
- return result
- }
-
-#if canImport(DTCoreText)
-
- private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
- guard let dtLayoutFrame else { return nil }
- return dtLayoutFrame.lineContaining(UInt(max(range.location, 0)))
- }
-#endif
-
- private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
- guard rects.count > 1 else { return rects }
-
- let sorted = rects.sorted { a, b in
- if abs(a.origin.y - b.origin.y) < 1 {
- return a.origin.x < b.origin.x
- }
- return a.origin.y < b.origin.y
- }
-
- var merged: [CGRect] = [sorted[0]]
- for rect in sorted.dropFirst() {
- let last = merged[merged.count - 1]
- if abs(rect.origin.y - last.origin.y) < 1,
- rect.origin.x <= last.maxX + 2 {
- merged[merged.count - 1] = last.union(rect)
- } else {
- merged.append(rect)
- }
- }
-
- return merged
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift
deleted file mode 100644
index 54be1e0..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift
+++ /dev/null
@@ -1,224 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBPageLine {
-
- let stringRange: NSRange
-
- let frame: CGRect
-
- let baselineOrigin: CGPoint
-
- let ascent: CGFloat
-
- let descent: CGFloat
-
- let leading: CGFloat
-}
-
-struct RDEPUBPageRun {
-
- let stringRange: NSRange
-
- let frame: CGRect
-
- let isAttachment: Bool
-}
-
-struct RDEPUBPageAttachment {
-
- let stringRange: NSRange
-
- let frame: CGRect
-
- let displaySize: CGSize
-
- let placement: RDEPUBTextAttachmentPlacement?
-
- let kind: RDEPUBTextAttachmentKind?
-}
-
-struct RDEPUBPageLayoutSnapshot {
-
- let page: RDEPUBTextPage
-
- let lines: [RDEPUBPageLine]
-
- let runs: [RDEPUBPageRun]
-
- let attachments: [RDEPUBPageAttachment]
-
- let pageContentRange: NSRange
-#if canImport(DTCoreText)
-
- let layoutFrame: DTCoreTextLayoutFrame
-#endif
-
- var contentBounds: CGRect {
- let rects = lines.map(\.frame) + attachments.map(\.frame)
- guard var bounds = rects.first else { return .zero }
- for rect in rects.dropFirst() {
- bounds = bounds.union(rect)
- }
- return bounds
- }
-
- func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
- lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
- }
-
- func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
- runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
- }
-
- func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
- runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
- }
-
- func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
- attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
- }
-
- func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
- lines.first { line in
- let lineRect = CGRect(
- x: line.frame.minX,
- y: line.baselineOrigin.y - line.ascent,
- width: max(line.frame.width, 1),
- height: line.ascent + line.descent + line.leading
- )
- return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
- }
- }
-
- func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
- runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
- }
-
- func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
- decorations.filter { decoration in
- decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
- }
- }
-
- func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
- if let attachment = attachment(at: point) {
- return attachment.stringRange
- }
- if let run = run(at: point) {
- return run.stringRange
- }
- let hitDecorations = rects(containing: point, in: decorations)
- if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
- lhs.absoluteRange.length < rhs.absoluteRange.length
- }) {
- return mostSpecific.absoluteRange
- }
- return line(at: point)?.stringRange
- }
-
-#if canImport(DTCoreText)
-
- static func build(
- from layoutFrame: DTCoreTextLayoutFrame,
- page: RDEPUBTextPage
- ) -> RDEPUBPageLayoutSnapshot? {
- guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
- return nil
- }
-
- // The layout frame is produced in chapter context, so DTCoreText
- // string ranges are already chapter-absolute.
- var lines: [RDEPUBPageLine] = []
- var runs: [RDEPUBPageRun] = []
- var attachments: [RDEPUBPageAttachment] = []
-
- for dtLine in dtLines {
- let lineRange = dtLine.stringRange()
- let line = RDEPUBPageLine(
- stringRange: lineRange,
- frame: dtLine.frame,
- baselineOrigin: dtLine.baselineOrigin,
- ascent: dtLine.ascent,
- descent: dtLine.descent,
- leading: dtLine.leading
- )
- lines.append(line)
-
- if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
- for run in glyphRuns {
- let runRange = run.stringRange()
- let isAttachment = run.attachment != nil
- runs.append(
- RDEPUBPageRun(
- stringRange: runRange,
- frame: run.frame,
- isAttachment: isAttachment
- )
- )
-
- guard isAttachment else { continue }
- let metadata = attachmentMetadata(
- for: runRange,
- on: page
- )
- attachments.append(
- RDEPUBPageAttachment(
- stringRange: runRange,
- frame: run.frame,
- displaySize: run.frame.size,
- placement: metadata.placement,
- kind: metadata.kind
- )
- )
- }
- }
- }
-
- let visibleRange = layoutFrame.visibleStringRange()
-
- return RDEPUBPageLayoutSnapshot(
- page: page,
- lines: lines,
- runs: runs,
- attachments: attachments,
- pageContentRange: visibleRange,
- layoutFrame: layoutFrame
- )
- }
-
- private static func attachmentMetadata(
- for range: NSRange,
- on page: RDEPUBTextPage
- ) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
- guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
- return (nil, nil)
- }
-
- let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
- ? page.metadata.attachmentPlacements[attachmentIndex]
- : nil
- let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
- ? page.metadata.attachmentKinds[attachmentIndex]
- : nil
- let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind
- return (placement, resolvedKind)
- }
-
- private static func attachmentKind(
- at range: NSRange,
- on page: RDEPUBTextPage
- ) -> RDEPUBTextAttachmentKind? {
- guard range.location >= 0,
- range.location < page.chapterContent.length else {
- return nil
- }
- let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil)
- return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
- }
-
-#endif
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift
deleted file mode 100644
index d2d9cde..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import UIKit
-
-final class RDEPUBSelectionLoupeView: UIView {
-
- private let imageView = UIImageView()
-
- private let magnification: CGFloat = 1.45
-
- private let captureSize = CGSize(width: 84, height: 84)
-
- override init(frame: CGRect) {
- super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
- isUserInteractionEnabled = false
- backgroundColor = .clear
- layer.shadowColor = UIColor.black.cgColor
- layer.shadowOpacity = 0.18
- layer.shadowRadius = 10
- layer.shadowOffset = CGSize(width: 0, height: 5)
-
- imageView.frame = bounds
- imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- imageView.layer.cornerRadius = bounds.width / 2
- imageView.layer.cornerCurve = .continuous
- imageView.layer.borderWidth = 1.5
- imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
- imageView.clipsToBounds = true
- addSubview(imageView)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
- imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
-
- let targetCenter = CGPoint(
- x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
- y: min(
- max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
- hostBounds.maxY - bounds.height / 2
- )
- )
-
- center = targetCenter
- if isHidden {
- alpha = 0
- transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
- isHidden = false
- UIView.animate(withDuration: 0.12) {
- self.alpha = 1
- self.transform = .identity
- }
- }
- }
-
- func dismiss() {
- guard !isHidden else { return }
- isHidden = true
- alpha = 0
- imageView.image = nil
- }
-
- private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
- let renderer = UIGraphicsImageRenderer(size: captureSize)
- return renderer.image { context in
- let cgContext = context.cgContext
- cgContext.setFillColor(UIColor.systemBackground.cgColor)
- cgContext.fill(CGRect(origin: .zero, size: captureSize))
- cgContext.translateBy(
- x: captureSize.width / 2 - focusPoint.x * magnification,
- y: captureSize.height / 2 - focusPoint.y * magnification
- )
- cgContext.scaleBy(x: magnification, y: magnification)
- sourceView.layer.render(in: cgContext)
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift
deleted file mode 100644
index afd7e35..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift
+++ /dev/null
@@ -1,177 +0,0 @@
-import UIKit
-
-struct RDEPUBTextOverlayDecoration {
-
- enum Kind: String {
-
- case selection
-
- case highlight
-
- case underline
-
- case search
-
- case activeSearch
-
- case locate
- }
-
- var kind: Kind
-
- var absoluteRange: NSRange
-
- var rects: [CGRect]
-
- var color: UIColor
-}
-
-class RDEPUBSelectionOverlayView: UIView {
-
- private(set) var page: RDEPUBTextPage?
-
- private var snapshot: RDEPUBPageLayoutSnapshot?
-
- private(set) var selectionRange: NSRange?
-
- private var selectionRects: [CGRect] = []
-
- private var decorations: [RDEPUBTextOverlayDecoration] = []
-
- private let selectionVerticalAdjustment: CGFloat = -1
-
- var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
- didSet {
- setNeedsDisplay()
- }
- }
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- isOpaque = false
- isUserInteractionEnabled = false
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
- self.page = page
- self.snapshot = snapshot
- self.selectionColor = selectionColor
- selectionRange = nil
- decorations = []
- setNeedsDisplay()
- }
-
- func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
- self.snapshot = snapshot
- }
-
- func updateSelection(absoluteRange: NSRange?) {
- selectionRange = absoluteRange
- selectionRects = []
- setNeedsDisplay()
- }
-
- func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
- selectionRange = absoluteRange
- selectionRects = rects
- setNeedsDisplay()
- }
-
- func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
- self.decorations = decorations
- setNeedsDisplay()
- }
-
- func clearSelection() {
- updateSelection(absoluteRange: nil)
- }
-
- func absoluteRange(at point: CGPoint) -> NSRange? {
- if let selectionRange,
- selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
- return selectionRange
- }
-
- if let snapshot,
- let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
- return absoluteRange
- }
-
- for decoration in resolvedDecorations {
- for rect in decoration.rects {
- if rect.insetBy(dx: -4, dy: -4).contains(point) {
- return decoration.absoluteRange
- }
- }
- }
- return nil
- }
-
- func decorationSummary() -> String {
- let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
- let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
- let highlightCount = counts[.highlight, default: 0]
- let underlineCount = counts[.underline, default: 0]
- let searchCount = counts[.search, default: 0]
- let activeSearchCount = counts[.activeSearch, default: 0]
- let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
- return [
- "selection \(selectionLabel)",
- "highlight \(highlightCount)",
- "underline \(underlineCount)",
- "search \(searchCount)",
- "activeSearch \(activeSearchCount)",
- "locate \(locateLabel)"
- ].joined(separator: " · ")
- }
-
- override func draw(_ rect: CGRect) {
- guard let context = UIGraphicsGetCurrentContext() else { return }
-
- for decoration in resolvedDecorations {
- switch decoration.kind {
- case .underline:
- context.setStrokeColor(decoration.color.cgColor)
- context.setLineWidth(2)
- for underlineRect in decoration.rects {
- let y = underlineRect.maxY - 1
- context.move(to: CGPoint(x: underlineRect.minX, y: y))
- context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
- context.strokePath()
- }
- default:
- context.setFillColor(decoration.color.cgColor)
- for selectionRect in decoration.rects {
- let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
- let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
- context.addPath(path.cgPath)
- context.fillPath()
- }
- }
- }
- }
-
- private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
- guard page != nil else { return [] }
-
- var result = decorations.filter { !$0.rects.isEmpty }
-
- if let selectionRange, !selectionRects.isEmpty {
- result.append(
- RDEPUBTextOverlayDecoration(
- kind: .selection,
- absoluteRange: selectionRange,
- rects: selectionRects,
- color: selectionColor
- )
- )
- }
-
- return result
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift
deleted file mode 100644
index 11a5caf..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-import UIKit
-
-final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
-
- private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
-
- private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
-
- func applyHighlights(
- _ highlights: [RDEPUBHighlight],
- to content: NSMutableAttributedString,
- page: RDEPUBTextPage,
- contentBaseOffset: Int
- ) {
- let pageRange = absoluteOffsetRange(for: page)
- let pageStart = pageRange.lowerBound
- let pageEndExclusive = pageRange.upperBound
-
- for highlight in highlights where highlight.location.href == page.href {
- guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
- let overlapStart = max(range.location, pageStart)
- let overlapEnd = min(range.location + range.length, pageEndExclusive)
- guard overlapStart < overlapEnd else { continue }
-
- let relativeRange = NSRange(
- location: overlapStart - contentBaseOffset,
- length: overlapEnd - overlapStart
- )
- switch highlight.style {
- case .highlight:
- content.addAttribute(
- .backgroundColor,
- value: UIColor(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35),
- range: relativeRange
- )
- case .underline:
- content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
- if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
- content.addAttribute(.underlineColor, value: color, range: relativeRange)
- }
- }
- }
- }
-
- func applySearchHighlights(
- to content: NSMutableAttributedString,
- page: RDEPUBTextPage,
- searchState: RDEPUBSearchState?,
- contentBaseOffset: Int
- ) {
- guard let searchState else { return }
-
- let pageRange = absoluteOffsetRange(for: page)
- let pageStart = pageRange.lowerBound
- let pageEndExclusive = pageRange.upperBound
-
- for match in searchState.matches {
- guard let matchStart = match.rangeLocation else { continue }
- let matchEnd = matchStart + match.rangeLength
- let overlapStart = max(matchStart, pageStart)
- let overlapEnd = min(matchEnd, pageEndExclusive)
- guard overlapStart < overlapEnd else { continue }
-
- let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
- let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
- content.addAttribute(.backgroundColor, value: color, range: relativeRange)
- }
- }
-
- func buildDecorations(
- page: RDEPUBTextPage,
- highlights: [RDEPUBHighlight],
- searchState: RDEPUBSearchState?,
- interactionController: RDEPUBPageInteractionController
- ) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
- var background: [RDEPUBTextOverlayDecoration] = []
- var foreground: [RDEPUBTextOverlayDecoration] = []
- let pageRange = absoluteOffsetRange(for: page)
- let pageStart = pageRange.lowerBound
- let pageEndExclusive = pageRange.upperBound
-
- if let searchState {
- for match in searchState.matches {
- guard let matchStart = match.rangeLocation else { continue }
- let matchEnd = matchStart + match.rangeLength
- let overlapStart = max(matchStart, pageStart)
- let overlapEnd = min(matchEnd, pageEndExclusive)
- guard overlapStart < overlapEnd else { continue }
-
- let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
- let rects = interactionController.selectionRects(for: absoluteRange)
- guard !rects.isEmpty else { continue }
-
- let isActive = match == searchState.currentMatch
- let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
- let color = isActive ? activeSearchColor : normalSearchColor
- background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
- }
- }
-
- for highlight in highlights where highlight.location.href == page.href {
- guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
- let overlapStart = max(range.location, pageStart)
- let overlapEnd = min(range.location + range.length, pageEndExclusive)
- guard overlapStart < overlapEnd else { continue }
-
- let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
- let rects = interactionController.selectionRects(for: absoluteRange)
- guard !rects.isEmpty else { continue }
-
- let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
- ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
- let decoration = RDEPUBTextOverlayDecoration(
- kind: highlight.style == .underline ? .underline : .highlight,
- absoluteRange: absoluteRange,
- rects: rects,
- color: color
- )
-
- if decoration.kind == .underline {
- foreground.append(decoration)
- } else {
- background.append(decoration)
- }
- }
-
- return (background, foreground)
- }
-
- private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range {
- let lowerBound = page.pageStartOffset
- let upperBound = page.pageEndOffset + 1
- return lowerBound.. Bool
- let currentSelectionProvider: () -> RDEPUBSelection?
- let isSelectionControllerSelecting: () -> Bool
- let hasActiveSelection: () -> Bool
- let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle?
- let selectionContainsPoint: (CGPoint) -> Bool
- let renderPointForGesture: (UIGestureRecognizer) -> CGPoint
- let renderPointForTouch: (UITouch) -> CGPoint
- let performLongPressSelection: (UILongPressGestureRecognizer) -> Void
- let performPanSelection: (UIPanGestureRecognizer) -> Void
- let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void
- let presentLoupeAtPoint: (CGPoint) -> Void
- let dismissLoupe: () -> Void
- let showSelectionMenu: () -> Void
- let hideSelectionMenu: () -> Void
- let selectionTapSuppressionDidChange: (Bool) -> Void
- let selectionPagingSuppressionDidChange: (Bool) -> Void
- }
-
- private let dependencies: Dependencies
-
- private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
-
- private(set) var interactionState: SelectionInteractionState = .idle
-
- var isInteractionInProgress: Bool {
- interactionState != .idle
- }
-
- init(dependencies: Dependencies) {
- self.dependencies = dependencies
- super.init()
- }
-
- func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
- dependencies.performLongPressSelection(gesture)
-
- switch gesture.state {
- case .began:
- activeSelectionHandle = nil
- updateSelectionInteractionState(.selecting)
- dependencies.hideSelectionMenu()
- dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
- case .changed:
- updateSelectionInteractionState(.selecting)
- dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
- case .ended:
- activeSelectionHandle = nil
- dependencies.dismissLoupe()
- dependencies.showSelectionMenu()
- case .cancelled, .failed:
- activeSelectionHandle = nil
- dependencies.dismissLoupe()
- default:
- break
- }
- }
-
- func handlePan(_ gesture: UIPanGestureRecognizer) {
- let point = dependencies.renderPointForGesture(gesture)
-
- if gesture.state == .began, activeSelectionHandle == nil,
- let handle = dependencies.selectionHandleAtPoint(point) {
- activeSelectionHandle = handle
- updateSelectionInteractionState(.adjustingHandle)
- dependencies.hideSelectionMenu()
- dependencies.presentLoupeAtPoint(point)
- }
-
- if let activeSelectionHandle {
- dependencies.adjustSelection(activeSelectionHandle, point)
- dependencies.presentLoupeAtPoint(point)
- } else {
- dependencies.performPanSelection(gesture)
- if dependencies.isSelectionControllerSelecting() {
- dependencies.presentLoupeAtPoint(point)
- }
- }
-
- switch gesture.state {
- case .ended:
- activeSelectionHandle = nil
- updateSelectionInteractionState(
- dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
- )
- dependencies.dismissLoupe()
- dependencies.showSelectionMenu()
- case .cancelled, .failed:
- activeSelectionHandle = nil
- updateSelectionInteractionState(
- dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
- )
- dependencies.dismissLoupe()
- default:
- break
- }
- }
-
- func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) {
- switch state {
- case .idle:
- if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil {
- updateSelectionInteractionState(.idle)
- }
- case .selecting:
- updateSelectionInteractionState(.selecting)
- case .selectionActive:
- updateSelectionInteractionState(
- dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
- )
- case .adjustingHandle:
- updateSelectionInteractionState(.adjustingHandle)
- }
- }
-
- func touchesBegan(_ touches: Set, with event: UIEvent?) {
- guard interactionState == .idle,
- activeSelectionHandle == nil,
- dependencies.hasRenderableContent(),
- let touch = touches.first else {
- return
- }
-
- let point = dependencies.renderPointForTouch(touch)
- guard dependencies.selectionHandleAtPoint(point) == nil else { return }
- updateSelectionInteractionState(.selectionPending)
- }
-
- func touchesEnded(_ touches: Set, with event: UIEvent?) {
- resetSelectionPendingIfNeeded()
- }
-
- func touchesCancelled(_ touches: Set, with event: UIEvent?) {
- resetSelectionPendingIfNeeded()
- }
-
- func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
- if gestureRecognizer is UIPanGestureRecognizer {
- if dependencies.isSelectionControllerSelecting() {
- return true
- }
- let point = dependencies.renderPointForGesture(gestureRecognizer)
- return dependencies.selectionHandleAtPoint(point) != nil
- }
-
- if gestureRecognizer is UILongPressGestureRecognizer {
- guard dependencies.hasRenderableContent() else {
- return true
- }
- let point = dependencies.renderPointForGesture(gestureRecognizer)
- if dependencies.selectionHandleAtPoint(point) != nil {
- return false
- }
- if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) {
- return false
- }
- return true
- }
-
- return true
- }
-
- func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
- guard dependencies.hasRenderableContent() else {
- return true
- }
-
- let point = dependencies.renderPointForTouch(touch)
- if let handle = dependencies.selectionHandleAtPoint(point) {
- if gestureRecognizer is UIPanGestureRecognizer {
- activeSelectionHandle = handle
- updateSelectionInteractionState(.adjustingHandle)
- dependencies.hideSelectionMenu()
- return true
- }
- if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer {
- return false
- }
- }
-
- return true
- }
-
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer
- }
-
- func reset() {
- activeSelectionHandle = nil
- updateSelectionInteractionState(.idle)
- dependencies.dismissLoupe()
- }
-
- private func resetSelectionPendingIfNeeded() {
- guard interactionState == .selectionPending else { return }
- if dependencies.currentSelectionProvider() != nil {
- updateSelectionInteractionState(.selectionActive)
- } else {
- updateSelectionInteractionState(.idle)
- }
- }
-
- private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
- let previousTapSuppressed = interactionState != .idle
- let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
- let previousState = interactionState
- interactionState = state
- let currentTapSuppressed = interactionState != .idle
- let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
- if previousState != state {
- RDEpubReaderTapDebug.log(
- "TextContentInteraction.state",
- "transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)"
- )
- }
- if previousTapSuppressed != currentTapSuppressed {
- dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
- }
- if previousPagingSuppressed != currentPagingSuppressed {
- dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed)
- }
- }
-
- private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
- switch state {
- case .idle, .selectionPending, .selectionActive:
- return false
- case .selecting, .adjustingHandle:
- return true
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
deleted file mode 100644
index 99c87e9..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
+++ /dev/null
@@ -1,1171 +0,0 @@
-import UIKit
-import Foundation
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-protocol RDEPUBTextContentViewDelegate: AnyObject {
- func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
- func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
- selection: RDEPUBSelection?
- )
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didActivateAttachmentText text: String,
- sourceRect: CGRect,
- sourcePoint: CGPoint
- )
-
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didActivateImage image: UIImage,
- sourceRect: CGRect,
- altText: String?
- )
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didRequestHighlightActions highlight: RDEPUBHighlight,
- sourceRect: CGRect
- )
-}
-
-extension RDEPUBTextContentViewDelegate {
- func textContentView(
- _ contentView: RDEPUBTextContentView,
- didActivateImage image: UIImage,
- sourceRect: CGRect,
- altText: String?
- ) {
- // Default: no-op. Implementors can present an image viewer.
- }
-}
-
-final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubReaderCachePolicyProviding {
-
- private static let pageNumberTrailingPadding: CGFloat = 4
- private static let pageNumberFooterPadding: CGFloat = 8
- private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
-
- private var contentInsets: UIEdgeInsets = .zero
-
- private var currentPage: RDEPUBTextPage?
- private var currentChapterCFIMap: RDEPUBCFIMap?
- private var currentChapterFragmentOffsets: [String: Int] = [:]
-
- private var currentSelection: RDEPUBSelection?
-
- private var menuSelection: RDEPUBSelection?
-
- // M-15: Backing store for UIEditMenuInteraction (iOS 16+).
- // Stored as Any? to avoid @available on stored property restriction.
- private var _editMenuInteraction: Any?
-
- private let selectionLoupeView = RDEPUBSelectionLoupeView()
-
- private var currentHighlights: [RDEPUBHighlight] = []
-
- private var currentSearchState: RDEPUBSearchState?
-
- weak var delegate: RDEPUBTextContentViewDelegate?
-
- var selectionTapSuppressionDidChange: ((Bool) -> Void)?
-
- var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
-
-#if canImport(DTCoreText)
-
- private let coreTextContentView: RDEPUBTextPageRenderView = {
- let view = RDEPUBTextPageRenderView()
- view.backgroundColor = .clear
- view.isOpaque = false
- view.isAccessibilityElement = true
- view.accessibilityTraits = .staticText
- return view
- }()
-
- /// Chapter-level shared display string owned by the display cache.
- /// Read-only from the page's perspective: theme color and dark-image
- /// adjustment are baked in at build time; page decoration goes through
- /// the overlay views.
- private var coreTextDisplayContent: NSAttributedString?
-
- private var coreTextDisplayRange: NSRange?
-
- /// Shared chapter-level layouter (owned by the display cache); layout
- /// frames are recomputed per page range and bounds change.
- private var coreTextLayouter: DTCoreTextLayouter?
-#endif
-
- private let interactionController = RDEPUBPageInteractionController()
-
- private let selectionController = RDEPUBTextSelectionController()
-
- private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
- let view = RDEPUBTextPageDecorationView()
- return view
- }()
-
- private let overlayView: RDEPUBTextAnnotationOverlay = {
- let view = RDEPUBTextAnnotationOverlay()
- return view
- }()
-
- private let coverImageView: UIImageView = {
- let view = UIImageView()
- view.contentMode = .scaleAspectFit
- view.isHidden = true
- return view
- }()
-
- private let pageNumberLabel: UILabel = {
- let label = UILabel()
- label.font = UIFont.systemFont(ofSize: 13)
- return label
- }()
-
- private let loadingSpinner: UIActivityIndicatorView = {
- let spinner = UIActivityIndicatorView(style: .medium)
- spinner.hidesWhenStopped = true
- spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
- return spinner
- }()
-
-#if DEBUG
- /// Debug-only outline of the readable content area (bounds inset by contentInsets).
- private let debugContentAreaBorderView: UIView = {
- let view = UIView()
- view.isUserInteractionEnabled = false
- view.backgroundColor = .clear
- view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
- view.layer.borderWidth = 1
- view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
- return view
- }()
-#endif
-
- private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
- let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
- gesture.minimumPressDuration = 0.5
- gesture.delegate = self
- return gesture
- }()
-
- private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
- let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
- gesture.isEnabled = false
- gesture.delegate = self
- return gesture
- }()
-
- private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
- let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
- gesture.delegate = self
- return gesture
- }()
-
- private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = {
- RDEPUBTextContentInteractionCoordinator(
- dependencies: .init(
- hasRenderableContent: { [weak self] in
- self?.hasInteractiveTextContent ?? false
- },
- currentSelectionProvider: { [weak self] in self?.currentSelection },
- isSelectionControllerSelecting: { [weak self] in
- self?.selectionController.isSelecting ?? false
- },
- hasActiveSelection: { [weak self] in
- self?.selectionController.hasActiveSelection ?? false
- },
- selectionHandleAtPoint: { [weak self] point in
- guard let renderView = self?.coreTextRenderView else { return nil }
- let handle = renderView.selectionHandle(at: point)
- switch handle {
- case .start:
- return .start
- case .end:
- return .end
- case nil:
- return nil
- }
- },
- selectionContainsPoint: { [weak self] point in
- self?.coreTextRenderView?.selectionContains(point) ?? false
- },
- renderPointForGesture: { [weak self] gesture in
- guard let self else { return .zero }
- return gesture.location(in: self.coreTextRenderView ?? self)
- },
- renderPointForTouch: { [weak self] touch in
- guard let self else { return .zero }
- return touch.location(in: self.coreTextRenderView ?? self)
- },
- performLongPressSelection: { [weak self] gesture in
- guard let self else { return }
- self.layoutIfNeeded()
- self.selectionController.handleLongPress(
- gesture,
- renderView: self.coreTextRenderView,
- interactionController: self.interactionController
- )
- },
- performPanSelection: { [weak self] gesture in
- guard let self else { return }
- self.layoutIfNeeded()
- self.selectionController.handlePan(
- gesture,
- renderView: self.coreTextRenderView,
- interactionController: self.interactionController
- )
- },
- adjustSelection: { [weak self] handle, point in
- guard let self else { return }
- self.selectionController.updateSelection(
- byAdjusting: handle,
- at: point,
- renderView: self.coreTextRenderView,
- interactionController: self.interactionController
- )
- },
- presentLoupeAtPoint: { [weak self] point in
- self?.updateSelectionLoupe(for: point)
- },
- dismissLoupe: { [weak self] in
- self?.selectionLoupeView.dismiss()
- },
- showSelectionMenu: { [weak self] in
- self?.showSelectionMenuIfNeeded()
- },
- hideSelectionMenu: { [weak self] in
- self?.hideSelectionMenu()
- },
- selectionTapSuppressionDidChange: { [weak self] isSuppressed in
- self?.selectionTapSuppressionDidChange?(isSuppressed)
- },
- selectionPagingSuppressionDidChange: { [weak self] isSuppressed in
- self?.selectionPagingSuppressionDidChange?(isSuppressed)
- }
- )
- )
- }()
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.content.view"
- addSubview(coverImageView)
-#if canImport(DTCoreText)
- addSubview(backgroundOverlayView)
- addSubview(coreTextContentView)
-#endif
- addSubview(overlayView)
- addSubview(pageNumberLabel)
- addSubview(loadingSpinner)
- addSubview(selectionLoupeView)
-#if DEBUG
- addSubview(debugContentAreaBorderView)
-#endif
- addGestureRecognizer(longPressGestureRecognizer)
- addGestureRecognizer(panGestureRecognizer)
- addGestureRecognizer(tapGestureRecognizer)
- tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
- selectionLoupeView.isHidden = true
-
- // M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
- if #available(iOS 16.0, *) {
- let interaction = UIEditMenuInteraction(delegate: self)
- addInteraction(interaction)
- self._editMenuInteraction = interaction
- }
-
- selectionController.onSelectionChanged = { [weak self] selection in
- guard let self else { return }
- self.currentSelection = selection
- if let selection {
- self.menuSelection = selection
- } else {
- self.hideSelectionMenu()
- }
- self.updateSelectionPanAvailability()
- self.updateViewInteractionAvailability()
- self.updateAccessibilityDecorationSummary()
- self.delegate?.textContentView(self, didChangeSelection: selection)
- }
- selectionController.interactionStateDidChange = { [weak self] state in
- guard let self else { return }
- self.interactionCoordinator.selectionControllerStateDidChange(state)
- self.updateSelectionPanAvailability()
- self.updateViewInteractionAvailability()
- }
- selectionController.pageProvider = { [weak self] in self?.currentPage }
- selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
- selectionController.chapterFragmentOffsetsProvider = { [weak self] in
- self?.currentChapterFragmentOffsets ?? [:]
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override var canBecomeFirstResponder: Bool { true }
-
- var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
- longPressGestureRecognizer
- }
-
- var isSelectionInteractionInProgress: Bool {
- interactionCoordinator.isInteractionInProgress
- }
-
- var shouldAvoidReaderPageCaching: Bool {
-#if canImport(DTCoreText)
- // In-context text layout keeps a chapter-length attributed copy and
- // layouter alive for the visible page. Avoid reader-level page
- // caching so preloading does not retain several full-chapter
- // display copies at once.
- // Do NOT relax this until chapter-level shared display content
- // lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached
- // pages would each pin their own full-chapter copy again.
- return currentPage == nil
- || loadingSpinner.isAnimating
- || coreTextDisplayContent != nil
-#else
- currentPage == nil
- || loadingSpinner.isAnimating
-#endif
- }
-
- private var hasInteractiveTextContent: Bool {
-#if canImport(DTCoreText)
- currentPage != nil && coreTextRenderView?.isHidden == false
-#else
- currentPage != nil
-#endif
- }
-
- override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
- action == #selector(rd_copy(_:))
- || action == #selector(rd_highlight(_:))
- || action == #selector(rd_annotate(_:))
- }
-
- @objc func rd_copy(_ sender: Any?) {
- performSelectionAction(.copy)
- }
-
- @objc func rd_highlight(_ sender: Any?) {
- performSelectionAction(.highlight)
- }
-
- @objc func rd_annotate(_ sender: Any?) {
- performSelectionAction(.annotate)
- }
-
- override func layoutSubviews() {
- super.layoutSubviews()
-
-#if canImport(DTCoreText)
- backgroundOverlayView.frame = bounds.inset(by: contentInsets)
- coreTextContentView.frame = bounds.inset(by: contentInsets)
- updateCoreTextLayoutFrameIfNeeded()
-#endif
- overlayView.frame = bounds.inset(by: contentInsets)
- coverImageView.frame = bounds.inset(by: contentInsets)
-
- let contentRect = bounds.inset(by: contentInsets)
-#if DEBUG
- debugContentAreaBorderView.frame = contentRect
-#endif
- let labelSize = pageNumberLabel.sizeThatFits(
- CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
- )
- let footerOriginY = bounds.maxY - contentInsets.bottom
- let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
- pageNumberLabel.frame = CGRect(
- x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
- y: footerOriginY + footerVerticalInset,
- width: labelSize.width,
- height: labelSize.height
- )
- loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
- }
-
- func configure(
- page: RDEPUBTextPage,
- pageNumber: Int,
- totalPages: Int,
- configuration: RDEPUBReaderConfiguration,
- chapterCFIMap: RDEPUBCFIMap? = nil,
- chapterFragmentOffsets: [String: Int] = [:],
- highlights: [RDEPUBHighlight] = [],
- searchState: RDEPUBSearchState? = nil,
- displayCache: RDEPUBChapterDisplayContentCache? = nil
- ) {
- loadingSpinner.stopAnimating()
- currentPage = page
- currentChapterCFIMap = chapterCFIMap
- currentChapterFragmentOffsets = chapterFragmentOffsets
- currentSelection = nil
- menuSelection = nil
- interactionCoordinator.reset()
- currentHighlights = highlights
- currentSearchState = searchState
- selectionController.clearSelection(renderView: coreTextRenderView)
- contentInsets = Self.safeContentInsets(
- configuration: configuration,
- safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
- )
- backgroundColor = configuration.theme.contentBackgroundColor
- pageNumberLabel.textColor = configuration.theme.contentTextColor
- pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
-
- if configureCoverIfNeeded(for: page) {
-#if canImport(DTCoreText)
- coreTextContentView.isHidden = true
- coreTextContentView.layoutFrame = nil
- coreTextDisplayContent = nil
- coreTextDisplayRange = nil
- coreTextLayouter = nil
-#endif
- updateStaticGestureAvailability()
- updateSelectionPanAvailability()
- updateViewInteractionAvailability()
- delegate?.textContentView(self, didChangeSelection: nil)
- setNeedsLayout()
- return
- }
-
- coverImageView.isHidden = true
- coverImageView.image = nil
-
-#if canImport(DTCoreText)
- // In-context display: lay out the full chapter string and render only
- // the page's range. CTTypesetter line breaks are context-sensitive, so
- // re-wrapping a page substring can break ±1 character away from the
- // paginator's line ends (lone characters spilling onto the last line);
- // laying out the same chapter string the paginator used cannot.
- // String indices in the layout frame are therefore chapter-absolute.
- // The chapter string and layouter are shared per chapter via the
- // display cache; this view must not mutate the shared string.
- let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
- .entry(for: page, configuration: configuration)
- let pageRange = NSIntersectionRange(
- page.contentRange,
- NSRange(location: 0, length: display.content.length)
- )
- coreTextContentView.isHidden = false
- coreTextContentView.backgroundColor = .clear
- coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
- coreTextDisplayContent = display.content
- coreTextDisplayRange = pageRange
- coreTextLayouter = display.layouter
- updateCoreTextLayoutFrameIfNeeded()
-#else
- let selectionContent = normalizedPageContent(from: page)
- let selectionRange = NSRange(location: 0, length: selectionContent.length)
- selectionContent.addAttribute(
- .foregroundColor,
- value: configuration.theme.contentTextColor,
- range: selectionRange
- )
- overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
- overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
-#endif
-
- overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
-#if canImport(DTCoreText)
- backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
-#endif
- updateStaticGestureAvailability()
- updateSelectionPanAvailability()
- updateViewInteractionAvailability()
- updateAccessibilityDecorationSummary()
-
- delegate?.textContentView(self, didChangeSelection: nil)
- setNeedsLayout()
- }
-
- func configureLoading(
- pageNumber: Int,
- totalPages: Int,
- configuration: RDEPUBReaderConfiguration
- ) {
- currentPage = nil
- currentChapterCFIMap = nil
- currentChapterFragmentOffsets = [:]
- currentSelection = nil
- menuSelection = nil
- interactionCoordinator.reset()
- currentHighlights = []
- currentSearchState = nil
- selectionController.clearSelection(renderView: coreTextRenderView)
- contentInsets = Self.safeContentInsets(
- configuration: configuration,
- safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
- )
- backgroundColor = configuration.theme.contentBackgroundColor
- pageNumberLabel.textColor = configuration.theme.contentTextColor
- pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
- coverImageView.isHidden = true
- coverImageView.image = nil
-
-#if canImport(DTCoreText)
- coreTextContentView.isHidden = false
- coreTextContentView.backgroundColor = .clear
- coreTextContentView.layoutFrame = nil
- coreTextDisplayContent = nil
- coreTextDisplayRange = nil
- coreTextLayouter = nil
-#endif
-
- overlayView.clearSelection()
- backgroundOverlayView.clearSelection()
- updateStaticGestureAvailability()
- updateSelectionPanAvailability()
- updateViewInteractionAvailability()
- delegate?.textContentView(self, didChangeSelection: nil)
- loadingSpinner.startAnimating()
- setNeedsLayout()
- }
-
- func clearSelection() {
- currentSelection = nil
- menuSelection = nil
- currentSearchState = nil
- interactionCoordinator.reset()
- selectionController.clearSelection(renderView: coreTextRenderView)
- overlayView.clearSelection()
- backgroundOverlayView.clearSelection()
- updateSelectionPanAvailability()
- updateViewInteractionAvailability()
- updateAccessibilityDecorationSummary()
- hideSelectionMenu()
- }
-
- private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
- let selection = currentSelection ?? menuSelection
- delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
- menuSelection = nil
- hideSelectionMenu()
- }
-
- private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
- guard page.pageIndexInChapter == 0,
- page.href.lowercased().contains("cover"),
- let image = coverImage(from: page.content) else {
- return false
- }
-
- coverImageView.image = image
- coverImageView.isHidden = false
-#if canImport(DTCoreText)
- coreTextContentView.isHidden = true
- coreTextContentView.layoutFrame = nil
- coreTextDisplayContent = nil
- coreTextDisplayRange = nil
- coreTextLayouter = nil
-#endif
- return true
- }
-
- private func coverImage(from content: NSAttributedString) -> UIImage? {
- guard content.length > 0 else { return nil }
- var resolvedImage: UIImage?
- content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
- guard let image = image(from: value) else { return }
- resolvedImage = image
- stop.pointee = true
- }
- return resolvedImage
- }
-
- private func image(from attachmentValue: Any?) -> UIImage? {
-#if canImport(DTCoreText)
- // 附件本体已持有图片时(含加密书解密后的图)优先使用,避免二次读盘或误读密文
- if let attachment = attachmentValue as? DTImageTextAttachment,
- let image = attachment.image {
- return image
- }
- if let attachment = attachmentValue as? DTTextAttachment,
- let url = attachment.contentURL {
- return UIImage(contentsOfFile: url.path)
- }
-#endif
- if let attachment = attachmentValue as? NSTextAttachment {
- if let image = attachment.image { return image }
- if let data = attachment.contents { return UIImage(data: data) }
- if let fileWrapper = attachment.fileWrapper,
- let data = fileWrapper.regularFileContents { return UIImage(data: data) }
- }
- return nil
- }
-
- private func imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? {
- guard page.chapterContent.length > attachment.stringRange.location else { return nil }
- let attachmentValue = page.chapterContent.attribute(
- .attachment,
- at: attachment.stringRange.location,
- effectiveRange: nil
- )
- return image(from: attachmentValue)
- }
-
- private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
- let content = NSMutableAttributedString(attributedString: page.content)
- guard content.length > 0 else { return content }
- let text = content.string as NSString
- let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
- guard firstParagraphRange.length > 0 else { return content }
-
- if shouldNormalizeLeadingParagraphSpacing(for: page) {
- let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
- updateParagraphStyle(in: content, range: leadingRange) { style in
- style.paragraphSpacingBefore = 0
- }
- }
-
- guard shouldNormalizeContinuationParagraph(for: page),
- !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
- return content
- }
-
- updateParagraphStyle(in: content, range: firstParagraphRange) { style in
- style.firstLineHeadIndent = style.headIndent
- style.paragraphSpacingBefore = 0
- }
- return content
- }
-
- private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
- page.pageStartOffset == 0
- }
-
- private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
- let text = content.string as NSString
- var index = 0
- while index < text.length {
- guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
- if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
- return text.paragraphRange(for: NSRange(location: index, length: 0))
- }
- index += 1
- }
- return nil
- }
-
- private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
- let pageStart = page.pageStartOffset
- guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
- let chapterText = page.chapterContent.string as NSString
- guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
- return !CharacterSet.newlines.contains(previousScalar)
- }
-
- private func updateParagraphStyle(
- in content: NSMutableAttributedString,
- range: NSRange,
- transform: (NSMutableParagraphStyle) -> Void
- ) {
- content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
- guard let style = value as? NSParagraphStyle else { return }
- let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
- transform(mutableStyle)
- content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
- }
- }
-
-
- private func firstParagraphUsesNonLeadingAlignment(
- in content: NSAttributedString,
- range: NSRange
- ) -> Bool {
- var usesNonLeadingAlignment = false
- content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
- guard let style = value as? NSParagraphStyle else { return }
- if style.alignment == .right || style.alignment == .center {
- usesNonLeadingAlignment = true
- stop.pointee = true
- }
- }
- return usesNonLeadingAlignment
- }
-
-#if canImport(DTCoreText)
-
- private func updateCoreTextLayoutFrameIfNeeded() {
- guard !coreTextContentView.isHidden,
- let displayContent = coreTextDisplayContent,
- let displayRange = coreTextDisplayRange,
- let page = currentPage,
- coreTextContentView.bounds.width > 0,
- coreTextContentView.bounds.height > 0 else {
- interactionController.configure(layoutFrame: nil, page: currentPage)
- return
- }
-
- guard let layouter = coreTextLayouter else {
- coreTextContentView.layoutFrame = nil
- interactionController.configure(layoutFrame: nil, page: page)
- return
- }
- let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
- if let layoutFrame {
- RDEPUBTextPageBoundaryValidator.validate(
- page: page,
- displayLayoutFrame: layoutFrame,
- displayContent: displayContent,
- displayBounds: coreTextContentView.bounds
- )
- RDEPUBTextPageVerticalJustifier.justify(
- layoutFrame,
- contentHeight: coreTextContentView.bounds.height,
- isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
- pixelScale: window?.screen.scale ?? UIScreen.main.scale
- )
- }
- coreTextContentView.layoutFrame = layoutFrame
- interactionController.configure(layoutFrame: layoutFrame, page: page)
- overlayView.updateSnapshot(interactionController.snapshot)
- backgroundOverlayView.updateSnapshot(interactionController.snapshot)
-
- if let page = currentPage {
- // Highlights/underlines and search are all drawn as overlay
- // decorations; the shared chapter display string carries no
- // page-level annotation attributes.
- let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
- page: page,
- highlights: currentHighlights,
- searchState: currentSearchState,
- interactionController: interactionController
- )
- backgroundOverlayView.applyDecorations(bgDecorations)
- overlayView.applyDecorations(fgDecorations)
- }
- }
-#endif
-
- @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
- interactionCoordinator.handleLongPress(gesture)
- }
-
- @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
- interactionCoordinator.handlePan(gesture)
- }
-
- private func updateSelectionPanAvailability() {
- let shouldEnablePan = selectionController.isSelecting
- || selectionController.hasActiveSelection
- || interactionCoordinator.interactionState == .adjustingHandle
- guard panGestureRecognizer.isEnabled != shouldEnablePan else { return }
- panGestureRecognizer.isEnabled = shouldEnablePan
- }
-
- private func updateViewInteractionAvailability() {
- let shouldEnableInteraction = hasInteractiveTextContent
- || selectionController.isSelecting
- || selectionController.hasActiveSelection
- || interactionCoordinator.interactionState != .idle
- guard isUserInteractionEnabled != shouldEnableInteraction else { return }
- isUserInteractionEnabled = shouldEnableInteraction
- RDEpubReaderTapDebug.log(
- "TextContentView.interactionAvailability",
- "updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)"
- )
- }
-
- private func updateStaticGestureAvailability() {
- let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false
- let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false
- let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress
- let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap
- guard longPressChanged || tapChanged else { return }
- longPressGestureRecognizer.isEnabled = shouldEnableLongPress
- tapGestureRecognizer.isEnabled = shouldEnableTap
- RDEpubReaderTapDebug.log(
- "TextContentView.gestureAvailability",
- "updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)"
- )
- }
-
- @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
- let point = gesture.location(in: overlayView)
- RDEpubReaderTapDebug.log(
- "TextContentView.handleTap",
- "received point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)"
- )
- if let renderView = coreTextRenderView {
- let renderPoint = gesture.location(in: renderView)
- if renderView.selectionHandle(at: renderPoint) != nil {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle")
- return
- }
- if currentSelection != nil {
- if renderView.selectionContains(renderPoint) {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu")
- showSelectionMenuIfNeeded()
- return
- }
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection")
- clearSelection()
- return
- }
- } else if currentSelection != nil {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection")
- clearSelection()
- return
- }
- // PRIORITY: Check for attachment tap
- if let page = currentPage,
- let attachment = interactionController.snapshot?.attachment(at: point) {
- let kind = attachment.kind
- let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero
- // Footnote attachments carry their note text in alt/accessibility metadata.
- // Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
- if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap")
- delegate?.textContentView(
- self,
- didActivateAttachmentText: footnoteText,
- sourceRect: sourceRect,
- sourcePoint: convert(point, from: overlayView)
- )
- return
- }
- // Regular image attachments: present image viewer
- if let image = imageFromPage(attachment: attachment, page: page) {
- let altText = attachmentText(at: point)
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)")
- delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
- return
- }
- }
- if let attachmentText = attachmentText(at: point),
- let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap")
- delegate?.textContentView(
- self,
- didActivateAttachmentText: attachmentText,
- sourceRect: sourceRect,
- sourcePoint: convert(point, from: overlayView)
- )
- return
- }
- guard let highlight = highlight(at: point),
- let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
- delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
- return
- }
- RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
- delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
- }
-
- private func showSelectionMenuIfNeeded() {
- guard currentSelection != nil,
- let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
- !targetRect.isEmpty else {
- return
- }
- let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
- if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
- becomeFirstResponder()
- let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
- let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
- DispatchQueue.main.async { [weak self, weak interaction] in
- guard let self, self.currentSelection != nil else { return }
- interaction?.presentEditMenu(with: config)
- }
- } else {
- becomeFirstResponder()
- let menuController = UIMenuController.shared
- menuController.menuItems = [
- UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
- UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
- UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
- ]
- menuController.setTargetRect(resolvedTargetRect, in: self)
- menuController.setMenuVisible(true, animated: true)
- }
- }
-
- private func hideSelectionMenu() {
- if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
- interaction.dismissMenu()
- } else {
- UIMenuController.shared.setMenuVisible(false, animated: true)
- }
- }
-
- private func updateSelectionLoupe(for point: CGPoint) {
-#if canImport(DTCoreText)
- guard let renderView = coreTextRenderView else { return }
- let localPoint = convert(point, from: renderView)
- selectionLoupeView.present(
- sourceView: renderView,
- focusPoint: point,
- hostBounds: bounds.inset(by: contentInsets),
- targetPoint: localPoint
- )
-#endif
- }
-
- private var coreTextRenderView: RDEPUBTextPageRenderView? {
-#if canImport(DTCoreText)
- return coreTextContentView
-#else
- return nil
-#endif
- }
-
- private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
- guard let renderView = coreTextRenderView else { return rect }
- return convert(rect, from: renderView)
- }
-
- private func updateAccessibilityDecorationSummary() {
-#if canImport(DTCoreText)
- coreTextContentView.accessibilityValue = [
- backgroundOverlayView.decorationSummary(),
- overlayView.decorationSummary()
- ].joined(separator: " | ")
-#endif
- }
-
- private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
- guard let page = currentPage else { return nil }
- let matches = currentHighlights.filter { highlight in
- guard highlight.location.href == page.href,
- let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
- return false
- }
- let overlap = NSIntersectionRange(range, page.contentRange)
- guard overlap.length > 0 else { return false }
- return interactionController.selectionRects(for: overlap).contains {
- $0.insetBy(dx: -4, dy: -4).contains(point)
- }
- }
- return matches.sorted { lhs, rhs in
- let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
- let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
- if lhs.hasNote != rhs.hasNote {
- return lhs.hasNote && !rhs.hasNote
- }
- return lhsRange < rhsRange
- }.first
- }
-
- private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
- guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
- let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
- return overlayView.convert(fallbackRect, to: self)
- }
- let rects = interactionController.selectionRects(for: range)
- if let first = rects.first {
- var unionRect = first
- for rect in rects.dropFirst() {
- unionRect = unionRect.union(rect)
- }
- return overlayView.convert(unionRect, to: self)
- }
- let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
- return overlayView.convert(fallbackRect, to: self)
- }
-
- private func attachmentText(at point: CGPoint) -> String? {
- guard let page = currentPage else { return nil }
- guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
- return nil
- }
- guard page.chapterContent.length > attachmentRange.location else {
- return nil
- }
- let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
-
-#if canImport(DTCoreText)
- if let textAttachment = attachment as? DTTextAttachment,
- let altText = (textAttachment.attributes["alt"] as? String)?
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !altText.isEmpty {
- return altText
- }
-#endif
-
- if let fileAttachment = attachment as? NSTextAttachment,
- let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
- !altText.isEmpty {
- return altText
- }
- return nil
- }
-
- private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
- if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
- return overlayView.convert(attachmentRect, to: self)
- }
- let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
- return overlayView.convert(fallbackRect, to: self)
- }
-
- func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
-#if canImport(DTCoreText)
- if interactionCoordinator.interactionState == .selectionPending {
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending")
- return true
- }
-
- guard let renderView = coreTextRenderView else {
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil")
- return false
- }
- let renderPoint = convert(point, to: renderView)
- if renderView.selectionHandle(at: renderPoint) != nil {
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle")
- return true
- }
- if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection")
- return true
- }
- switch interactionCoordinator.interactionState {
- case .idle:
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle")
- return false
- case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
- RDEpubReaderTapDebug.log(
- "TextContentView.shouldSuppressReaderTap",
- "return true because interactionState=\(interactionCoordinator.interactionState)"
- )
- return true
- }
-#else
- RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable")
- return false
-#endif
- }
-
- override func touchesBegan(_ touches: Set, with event: UIEvent?) {
- super.touchesBegan(touches, with: event)
- interactionCoordinator.touchesBegan(touches, with: event)
- }
-
- override func touchesEnded(_ touches: Set, with event: UIEvent?) {
- super.touchesEnded(touches, with: event)
- interactionCoordinator.touchesEnded(touches, with: event)
- }
-
- override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
- super.touchesCancelled(touches, with: event)
- interactionCoordinator.touchesCancelled(touches, with: event)
- }
-
- override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
- if gestureRecognizer === tapGestureRecognizer {
- guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
- return false
- }
- if selectionController.hasActiveSelection {
- if let renderView = coreTextRenderView {
- let point = tapGestureRecognizer.location(in: renderView)
- if renderView.selectionHandle(at: point) != nil {
- return false
- }
- }
- return true
- }
- let point = tapGestureRecognizer.location(in: overlayView)
- if attachmentText(at: point) != nil || highlight(at: point) != nil {
- return true
- }
- return currentPage != nil
- }
- return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
- }
-
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldReceive touch: UITouch
- ) -> Bool {
- if gestureRecognizer === tapGestureRecognizer {
- guard let renderView = coreTextRenderView else {
- RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil")
- return true
- }
-
- let point = touch.location(in: renderView)
- if renderView.selectionHandle(at: point) != nil {
- RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle")
- return false
- }
- }
-
- let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
- RDEpubReaderTapDebug.log(
- "TextContentView.gestureShouldReceive",
- "gesture=\(type(of: gestureRecognizer)) touchView=\(RDEpubReaderTapDebug.describe(touch.view)) result=\(result)"
- )
- return result
- }
-
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- gestureRecognizer === tapGestureRecognizer
- || interactionCoordinator.gestureRecognizer(
- gestureRecognizer,
- shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
- )
- }
-}
-
-// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
-
-@available(iOS 16.0, *)
-extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
- func editMenuInteraction(
- _ interaction: UIEditMenuInteraction,
- menuFor configuration: UIEditMenuConfiguration,
- suggestedActions: [UIMenuElement]
- ) -> UIMenu? {
- UIMenu(children: [
- UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
- UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
- UICommand(title: "批注", action: #selector(rd_annotate(_:)))
- ])
- }
-
- func editMenuInteraction(
- _ interaction: UIEditMenuInteraction,
- targetRectFor configuration: UIEditMenuConfiguration
- ) -> CGRect {
- if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
- !targetRect.isEmpty {
- return resolvedSelectionMenuTargetRect(from: targetRect)
- }
- return bounds
- }
-}
-
-// MARK: - Safe Content Insets
-
-extension RDEPUBTextContentView {
-
- /// Computes content insets that account for safe areas (Dynamic Island, home indicator).
- /// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
- /// ensuring content is never hidden under the Dynamic Island or home indicator area.
- private static func safeContentInsets(
- configuration: RDEPUBReaderConfiguration,
- safeAreaInsets: UIEdgeInsets
- ) -> UIEdgeInsets {
- let configInsets = configuration.reflowableContentInsets
- return UIEdgeInsets(
- top: max(configInsets.top, safeAreaInsets.top),
- left: max(configInsets.left, safeAreaInsets.left),
- bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
- right: max(configInsets.right, safeAreaInsets.right)
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift
deleted file mode 100644
index 323f771..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift
+++ /dev/null
@@ -1,223 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-
-/// Debug-only detector for pagination/display metric mismatches, enabled by
-/// the `--demo-pagination-validate` launch argument.
-///
-/// For every displayed text page it re-wraps the chapter text from the page
-/// start at the display width (same CoreText engine the paginator used, in
-/// chapter context) and compares the resulting line breaks against both the
-/// page range and the lines actually drawn. Two failure classes:
-///
-/// - `STALE-RANGE`: the page range does not end on a line boundary of the
-/// current metrics — the range was produced under different metrics than
-/// the ones on screen (stale page table).
-/// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at
-/// different offsets than the in-context wrap — the display-side content
-/// transform (e.g. continuation-paragraph normalization) changed wrapping.
-enum RDEPUBTextPageBoundaryValidator {
-
- static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate")
-
- /// Extra characters wrapped past the page end so the probe can see the
- /// line that a mid-line page boundary cuts through.
- private static let probeTailLength = 400
-
- static func validate(
- page: RDEPUBTextPage,
- displayLayoutFrame: DTCoreTextLayoutFrame,
- displayContent: NSAttributedString?,
- displayBounds: CGRect
- ) {
- guard isEnabled else { return }
- let chapter = page.chapterContent
- let pageStart = page.contentRange.location
- let pageEnd = page.contentRange.location + page.contentRange.length
- guard page.contentRange.length > 0,
- pageStart >= 0,
- pageEnd <= chapter.length,
- displayBounds.width > 0 else { return }
-
- guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return }
- layouter.shouldCacheLayoutFrames = false
- let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength)
- let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000)
- guard let probeFrame = layouter.layoutFrame(
- with: probeRect,
- range: NSRange(location: pageStart, length: probeLength)
- ), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return }
-
- let probeRanges = probeLines.map { $0.stringRange() }
-
- // Class A: the page must end on a line boundary of the current wrap
- // (unless it is the chapter's last page, which ends at chapter end).
- let isChapterLastPage = pageEnd >= chapter.length
- if !isChapterLastPage,
- !probeRanges.contains(where: { NSMaxRange($0) == pageEnd }),
- let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) {
- let text = chapter.string as NSString
- let lineText = safeSubstring(text, cutLine)
- print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"")
- }
-
- // Class B: the drawn lines must break at the same offsets as the
- // in-context wrap. The display layout frame is built in chapter
- // context, so its string ranges are chapter-absolute.
- guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return }
- for (index, displayLine) in displayLines.enumerated() {
- let displayRange = displayLine.stringRange()
- let displayEndInChapter = NSMaxRange(displayRange)
- guard displayEndInChapter < pageEnd else { break }
- guard index < probeRanges.count else { break }
- let probeEnd = NSMaxRange(probeRanges[index])
- if probeEnd != displayEndInChapter {
- let text = chapter.string as NSString
- let lineStartInChapter = displayRange.location
- let displayLineRangeInChapter = displayRange
- let isParagraphStart = lineStartInChapter == 0
- || text.character(at: lineStartInChapter - 1) == 0x0A
- let chapterStyle = chapter.attribute(
- .paragraphStyle, at: lineStartInChapter, effectiveRange: nil
- ) as? NSParagraphStyle
- let displayStyle = displayContent?.attribute(
- .paragraphStyle, at: displayRange.location, effectiveRange: nil
- ) as? NSParagraphStyle
- let displayLineWidth = displayLine.frame.width
- let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1
- print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"…\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"…\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"")
- diagnoseDivergence(
- page: page,
- displayContent: displayContent,
- lineIndex: index,
- lineStartInChapter: lineStartInChapter,
- displayEndInChapter: displayEndInChapter,
- probeEnd: probeEnd,
- width: displayBounds.width
- )
- break
- }
- }
- }
-
- /// Narrows a display/probe line-break divergence down to its cause by
- /// re-wrapping controlled variants and diffing attributes over the line.
- private static func diagnoseDivergence(
- page: RDEPUBTextPage,
- displayContent: NSAttributedString?,
- lineIndex: Int,
- lineStartInChapter: Int,
- displayEndInChapter: Int,
- probeEnd: Int,
- width: CGFloat
- ) {
- let chapter = page.chapterContent
-
- // Variant 1: the raw page substring with no display normalization.
- let rawSubstring = chapter.attributedSubstring(from: page.contentRange)
- let rawEnd = lineEnd(
- wrapping: rawSubstring,
- lineIndex: lineIndex,
- width: width
- ).map { $0 + page.pageStartOffset }
-
- // Variant 2: wrap the chapter from the start of the paragraph that
- // contains the diverging line (context = current paragraph only).
- let text = chapter.string as NSString
- let paragraphRange = text.paragraphRange(
- for: NSRange(location: lineStartInChapter, length: 0)
- )
- let paraString = chapter.attributedSubstring(
- from: NSRange(
- location: paragraphRange.location,
- length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength)
- )
- )
- var paraEnd: Int?
- if let layouter = DTCoreTextLayouter(attributedString: paraString) {
- layouter.shouldCacheLayoutFrames = false
- let frame = layouter.layoutFrame(
- with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
- range: NSRange(location: 0, length: paraString.length)
- )
- if let lines = frame?.lines as? [DTCoreTextLayoutLine] {
- let target = lineStartInChapter - paragraphRange.location
- if let matched = lines.first(where: { $0.stringRange().location == target }) {
- paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location
- }
- }
- }
-
- print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)")
-
- // Attribute diff between chapter text and display content over the
- // diverging line (through the longer of the two ends). The display
- // content is a chapter-length copy, so indices are shared.
- guard let displayContent else { return }
- let diffEnd = max(displayEndInChapter, probeEnd)
- var position = lineStartInChapter
- while position < diffEnd {
- guard position >= 0, position < displayContent.length, position < chapter.length else { break }
- var chapterRunRange = NSRange()
- let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange)
- var displayRunRange = NSRange()
- let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange)
-
- let keys = Set(chapterAttrs.keys).union(displayAttrs.keys)
- for key in keys {
- let lhs = chapterAttrs[key] as AnyObject?
- let rhs = displayAttrs[key] as AnyObject?
- if let lhs, let rhs, lhs.isEqual(rhs) { continue }
- if lhs == nil, rhs == nil { continue }
- print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))")
- }
- let nextPosition = min(
- NSMaxRange(chapterRunRange),
- NSMaxRange(displayRunRange)
- )
- guard nextPosition > position else { break }
- position = nextPosition
- }
- }
-
- private static func describeAttr(_ value: AnyObject?) -> String {
- guard let value else { return "nil" }
- if let font = value as? UIFont {
- return "font(\(font.fontName),\(font.pointSize))"
- }
- if let style = value as? NSParagraphStyle {
- return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))"
- }
- if let number = value as? NSNumber {
- return "num(\(number))"
- }
- return String(describing: type(of: value))
- }
-
- /// Wraps `content` page-locally and returns the chapter-relative end of
- /// line `lineIndex`, or nil if it cannot be produced.
- private static func lineEnd(
- wrapping content: NSAttributedString,
- lineIndex: Int,
- width: CGFloat
- ) -> Int? {
- guard content.length > 0,
- let layouter = DTCoreTextLayouter(attributedString: content) else { return nil }
- layouter.shouldCacheLayoutFrames = false
- let frame = layouter.layoutFrame(
- with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
- range: NSRange(location: 0, length: content.length)
- )
- guard let lines = frame?.lines as? [DTCoreTextLayoutLine],
- lineIndex < lines.count else { return nil }
- return NSMaxRange(lines[lineIndex].stringRange())
- }
-
- private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String {
- guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" }
- return text.substring(with: range)
- .replacingOccurrences(of: "\n", with: "⏎")
- }
-}
-#endif
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift
deleted file mode 100644
index 3bb438e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift
+++ /dev/null
@@ -1,3 +0,0 @@
-import UIKit
-
-final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift
deleted file mode 100644
index b72036d..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift
+++ /dev/null
@@ -1,213 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-
-final class RDEPUBTextPageRenderView: UIView {
-
- enum SelectionHandle {
- case start
- case end
- }
-
- var layoutFrame: DTCoreTextLayoutFrame? {
- didSet {
- invalidateStaticContent()
- }
- }
-
- var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
- didSet {
- invalidateStaticContent()
- }
- }
-
- var selectionRects: [CGRect] = [] {
- didSet {
- setNeedsDisplay()
- }
- }
-
- var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
-
- private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
-
- private let selectionHandleStemWidth: CGFloat = 2.5
-
- private let selectionHandleKnobRadius: CGFloat = 7
-
- private let selectionHandleHitSlop: CGFloat = 20
-
- private var cachedStaticImage: UIImage?
-
- private var cachedStaticBoundsSize: CGSize = .zero
-
- private var needsStaticContentRedraw = true
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- isOpaque = false
- contentMode = .redraw
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func draw(_ rect: CGRect) {
- guard let context = UIGraphicsGetCurrentContext(),
- let layoutFrame else { return }
-
- context.saveGState()
-
- if cachedStaticImage == nil
- || cachedStaticBoundsSize != bounds.size
- || needsStaticContentRedraw {
- cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
- cachedStaticBoundsSize = bounds.size
- needsStaticContentRedraw = false
- }
-
- if let cachedStaticImage {
- cachedStaticImage.draw(in: bounds)
- } else {
- layoutFrame.draw(in: context, options: drawOptions)
- }
-
- drawSelection(in: context)
-
- context.restoreGState()
- }
-
- override func layoutSubviews() {
- super.layoutSubviews()
- if cachedStaticBoundsSize != bounds.size {
- invalidateStaticContent()
- }
- }
-
- private func drawSelection(in context: CGContext) {
- guard !selectionRects.isEmpty else { return }
- selectionColor.setFill()
- for rect in selectionRects {
- context.fill(rect)
- }
- drawSelectionHandles(in: context)
- }
-
- func selectionHandle(at point: CGPoint) -> SelectionHandle? {
- guard let handleGeometry = selectionHandleGeometry else { return nil }
-
- let startDistance = point.distance(to: handleGeometry.startKnobCenter)
- let endDistance = point.distance(to: handleGeometry.endKnobCenter)
- let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
-
- let startMatched = startDistance <= maxDistance
- let endMatched = endDistance <= maxDistance
-
- switch (startMatched, endMatched) {
- case (true, true):
- return startDistance <= endDistance ? .start : .end
- case (true, false):
- return .start
- case (false, true):
- return .end
- default:
- return nil
- }
- }
-
- func selectionContains(_ point: CGPoint) -> Bool {
- selectionRects.contains { rect in
- rect.insetBy(dx: -6, dy: -8).contains(point)
- }
- }
-
- private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
- guard let firstRect = selectionRects.first,
- let lastRect = selectionRects.last else {
- return nil
- }
-
- let startKnobCenter = CGPoint(
- x: firstRect.minX,
- y: firstRect.minY - selectionHandleKnobRadius
- )
- let endKnobCenter = CGPoint(
- x: lastRect.maxX,
- y: lastRect.maxY + selectionHandleKnobRadius
- )
-
- return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
- }
-
- private func drawSelectionHandles(in context: CGContext) {
- guard let firstRect = selectionRects.first,
- let lastRect = selectionRects.last else {
- return
- }
-
- context.saveGState()
- context.setFillColor(selectionHandleColor.cgColor)
-
- let stemHalfWidth = selectionHandleStemWidth / 2
-
- let startStem = CGRect(
- x: firstRect.minX - stemHalfWidth,
- y: firstRect.minY - selectionHandleKnobRadius * 2,
- width: selectionHandleStemWidth,
- height: firstRect.height + selectionHandleKnobRadius * 2
- )
- context.fill(startStem)
- let startKnob = CGRect(
- x: firstRect.minX - selectionHandleKnobRadius,
- y: firstRect.minY - selectionHandleKnobRadius * 2,
- width: selectionHandleKnobRadius * 2,
- height: selectionHandleKnobRadius * 2
- )
- context.fillEllipse(in: startKnob)
-
- let endStem = CGRect(
- x: lastRect.maxX - stemHalfWidth,
- y: lastRect.minY,
- width: selectionHandleStemWidth,
- height: lastRect.height + selectionHandleKnobRadius * 2
- )
- context.fill(endStem)
- let endKnob = CGRect(
- x: lastRect.maxX - selectionHandleKnobRadius,
- y: lastRect.maxY,
- width: selectionHandleKnobRadius * 2,
- height: selectionHandleKnobRadius * 2
- )
- context.fillEllipse(in: endKnob)
-
- context.restoreGState()
- }
-
- private func invalidateStaticContent() {
- cachedStaticImage = nil
- needsStaticContentRedraw = true
- setNeedsDisplay()
- }
-
- private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
- guard bounds.width > 0, bounds.height > 0 else { return nil }
- let format = UIGraphicsImageRendererFormat.default()
- format.opaque = false
- let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
- return renderer.image { _ in
- guard let staticContext = UIGraphicsGetCurrentContext() else { return }
- layoutFrame.draw(in: staticContext, options: drawOptions)
- }
- }
-}
-
-private extension CGPoint {
-
- func distance(to point: CGPoint) -> CGFloat {
- hypot(x - point.x, y - point.y)
- }
-}
-#endif
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift
deleted file mode 100644
index 01aae72..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-
-/// Redistributes the leftover space at the bottom of a page into the gaps
-/// between lines so the last line sits flush with the content bottom edge
-/// (vertical justification), keeping page character ranges untouched.
-///
-/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives
-/// line frames, glyph-run frames and attachment positions from it lazily,
-/// so drawing, selection, highlights and hit-testing all stay consistent.
-enum RDEPUBTextPageVerticalJustifier {
-
- /// Leftover larger than this many typical line advances is kept as
- /// whitespace instead of being stretched: it usually comes from a whole
- /// block (image, table) pushed to the next page, and stretching would
- /// make the line spacing visibly sparse.
- static let maxStretchLineAdvanceRatio: CGFloat = 1.5
-
- static func justify(
- _ layoutFrame: DTCoreTextLayoutFrame,
- contentHeight: CGFloat,
- isChapterLastPage: Bool,
- pixelScale: CGFloat
- ) {
- guard !isChapterLastPage,
- contentHeight > 0,
- let lines = layoutFrame.lines as? [DTCoreTextLayoutLine],
- lines.count >= 2,
- let firstLine = lines.first,
- let lastLine = lines.last else {
- return
- }
-
- let leftover = contentHeight - lastLine.frame.maxY
- guard leftover > 0.5 else { return }
-
- let gapCount = CGFloat(lines.count - 1)
- let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount
- guard typicalAdvance > 0,
- leftover <= typicalAdvance * maxStretchLineAdvanceRatio else {
- return
- }
-
- let scale = max(pixelScale, 1)
- for (index, line) in lines.enumerated() where index > 0 {
- // Round each cumulative shift down to the pixel grid so glyphs
- // stay sharp and the last line never overshoots the bottom edge.
- let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale
- var origin = line.baselineOrigin
- origin.y += shift
- line.baselineOrigin = origin
- }
- }
-}
-#endif
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift
deleted file mode 100644
index c732c3b..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift
+++ /dev/null
@@ -1,388 +0,0 @@
-import UIKit
-
-final class RDEPUBTextSelectionController: NSObject {
-
- enum BoundaryHandle {
- case start
- case end
- }
-
- enum InteractionState: Equatable {
- case idle
- case selecting
- case selectionActive
- case adjustingHandle
- }
-
- private enum SelectionGranularity {
- case character
- case word
- }
-
- private(set) var isSelecting = false
-
- private var selectionStartIndex: Int = NSNotFound
-
- private var selectionEndIndex: Int = NSNotFound
-
- private var activeGranularity: SelectionGranularity = .character
-
- private var selectionAnchorIndex: Int = NSNotFound
-
- private(set) var interactionState: InteractionState = .idle {
- didSet {
- guard interactionState != oldValue else { return }
- interactionStateDidChange?(interactionState)
- }
- }
-
- var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
-
- var interactionStateDidChange: ((InteractionState) -> Void)?
-
- var pageProvider: (() -> RDEPUBTextPage?)?
-
- var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)?
-
- var chapterFragmentOffsetsProvider: (() -> [String: Int])?
-
- var hasActiveSelection: Bool {
- selectedAbsoluteRange != nil
- }
-
- var selectedAbsoluteRange: NSRange? {
- guard selectionStartIndex != NSNotFound,
- selectionEndIndex != NSNotFound else {
- return nil
- }
- let lower = min(selectionStartIndex, selectionEndIndex)
- let upper = max(selectionStartIndex, selectionEndIndex)
- return NSRange(location: lower, length: max(upper - lower + 1, 1))
- }
-
- func handleLongPress(
- _ gesture: UILongPressGestureRecognizer,
- renderView: RDEPUBTextPageRenderView?,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard let renderView else { return }
- let point = gesture.location(in: renderView)
-
- switch gesture.state {
- case .began:
- setInteractionState(.selecting)
- beginSelection(at: point, renderView: renderView, interactionController: interactionController)
- case .changed:
- setInteractionState(.selecting)
- updateSelection(at: point, renderView: renderView, interactionController: interactionController)
- case .ended:
- isSelecting = false
- setInteractionState(hasActiveSelection ? .selectionActive : .idle)
- case .cancelled, .failed:
- clearSelection(renderView: renderView)
- default:
- break
- }
- }
-
- func handlePan(
- _ gesture: UIPanGestureRecognizer,
- renderView: RDEPUBTextPageRenderView?,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard isSelecting, let renderView else { return }
- let point = gesture.location(in: renderView)
-
- switch gesture.state {
- case .began, .changed:
- setInteractionState(.selecting)
- updateSelection(at: point, renderView: renderView, interactionController: interactionController)
- case .ended, .cancelled, .failed:
- isSelecting = false
- setInteractionState(hasActiveSelection ? .selectionActive : .idle)
- default:
- break
- }
- }
-
- func updateSelection(
- byAdjusting handle: BoundaryHandle,
- at point: CGPoint,
- renderView: RDEPUBTextPageRenderView?,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard let renderView,
- let index = interactionController.characterIndexForViewPoint(at: point, in: renderView),
- selectedAbsoluteRange != nil else {
- return
- }
-
- setInteractionState(.adjustingHandle)
-
- switch handle {
- case .start:
- selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start)
- if selectionEndIndex != NSNotFound {
- selectionStartIndex = min(selectionStartIndex, selectionEndIndex)
- }
- case .end:
- selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end)
- if selectionStartIndex != NSNotFound {
- selectionEndIndex = max(selectionEndIndex, selectionStartIndex)
- }
- }
-
- applySelection(renderView: renderView, interactionController: interactionController)
- }
-
- func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
- guard let absoluteRange = selectedAbsoluteRange else { return nil }
- return interactionController.menuAnchorRect(for: absoluteRange)
- }
-
- func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
- isSelecting = false
- selectionAnchorIndex = NSNotFound
- activeGranularity = .character
- selectionStartIndex = NSNotFound
- selectionEndIndex = NSNotFound
- setInteractionState(.idle)
- renderView?.selectionRects = []
- onSelectionChanged?(nil)
- }
-
- private func beginSelection(
- at point: CGPoint,
- renderView: RDEPUBTextPageRenderView,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
- if !isSelecting {
- clearSelection(renderView: renderView)
- }
- return
- }
- selectionAnchorIndex = index
- activeGranularity = .word
- isSelecting = true
- applySelection(
- range: selectionRange(for: index, granularity: .word),
- renderView: renderView,
- interactionController: interactionController
- )
- }
-
- private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? {
- guard let page = pageProvider?() else { return nil }
-
- switch granularity {
- case .character:
- return NSRange(location: focusIndex, length: 1)
- case .word:
- if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) {
- return wordRange
- }
- return NSRange(location: focusIndex, length: 1)
- }
- }
-
- private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int {
- guard let page = pageProvider?() else { return index }
- let text = page.chapterContent.string as NSString
- guard let wordRange = wordRange(containing: index, in: text) else {
- return index
- }
-
- switch handle {
- case .start:
- return wordRange.location
- case .end:
- return max(wordRange.location + wordRange.length - 1, wordRange.location)
- }
- }
-
- private func wordRange(containing index: Int, in text: NSString) -> NSRange? {
- guard text.length > 0 else { return nil }
- let safeIndex = min(max(index, 0), max(text.length - 1, 0))
- if let scalar = UnicodeScalar(text.character(at: safeIndex)),
- CharacterSet.whitespacesAndNewlines.contains(scalar) {
- return nearestWordRange(to: safeIndex, in: text)
- ?? text.rangeOfComposedCharacterSequence(at: safeIndex)
- }
- let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex)
- let probeRange = NSRange(location: safeIndex, length: 1)
- var matchedWordRange: NSRange?
-
- text.enumerateSubstrings(
- in: NSRange(location: 0, length: text.length),
- options: [.byWords, .substringNotRequired]
- ) { _, substringRange, _, stop in
- guard substringRange.length > 0 else { return }
- if NSIntersectionRange(substringRange, probeRange).length > 0
- || NSLocationInRange(characterRange.location, substringRange) {
- matchedWordRange = substringRange
- stop.pointee = true
- }
- }
-
- if let matchedWordRange {
- let trimmedRange = trimmed(range: matchedWordRange, in: text)
- if trimmedRange.length > 0 {
- return trimmedRange
- }
- }
-
- return characterRange
- }
-
- private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? {
- var nearestRange: NSRange?
- var nearestDistance = Int.max
-
- text.enumerateSubstrings(
- in: NSRange(location: 0, length: text.length),
- options: [.byWords, .substringNotRequired]
- ) { _, substringRange, _, _ in
- guard substringRange.length > 0 else { return }
- let trimmedRange = self.trimmed(range: substringRange, in: text)
- guard trimmedRange.length > 0 else { return }
-
- let distance: Int
- if index < trimmedRange.location {
- distance = trimmedRange.location - index
- } else if index >= trimmedRange.location + trimmedRange.length {
- distance = index - (trimmedRange.location + trimmedRange.length - 1)
- } else {
- distance = 0
- }
-
- if distance < nearestDistance {
- nearestDistance = distance
- nearestRange = trimmedRange
- }
- }
-
- return nearestRange
- }
-
- private func trimmed(range: NSRange, in text: NSString) -> NSRange {
- guard range.length > 0 else { return range }
-
- var lowerBound = range.location
- var upperBound = range.location + range.length
-
- while lowerBound < upperBound,
- let scalar = UnicodeScalar(text.character(at: lowerBound)),
- CharacterSet.whitespacesAndNewlines.contains(scalar) {
- lowerBound += 1
- }
-
- while upperBound > lowerBound,
- let scalar = UnicodeScalar(text.character(at: upperBound - 1)),
- CharacterSet.whitespacesAndNewlines.contains(scalar) {
- upperBound -= 1
- }
-
- return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0))
- }
-
- private func applySelection(
- range: NSRange?,
- renderView: RDEPUBTextPageRenderView,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard let range, range.location != NSNotFound, range.length > 0 else {
- clearSelection(renderView: renderView)
- return
- }
- selectionStartIndex = range.location
- selectionEndIndex = range.location + range.length - 1
- applySelection(renderView: renderView, interactionController: interactionController)
- }
-
- private func updateSelection(
- at point: CGPoint,
- renderView: RDEPUBTextPageRenderView,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard selectionAnchorIndex != NSNotFound,
- let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
- return
- }
-
- let clampedIndex: Int
- switch activeGranularity {
- case .character:
- clampedIndex = index
- case .word:
- clampedIndex = snappedBoundaryIndex(
- for: index,
- handle: index >= selectionAnchorIndex ? .end : .start
- )
- }
-
- selectionStartIndex = selectionAnchorIndex
- selectionEndIndex = clampedIndex
- applySelection(renderView: renderView, interactionController: interactionController)
- }
-
- private func applySelection(
- renderView: RDEPUBTextPageRenderView,
- interactionController: RDEPUBPageInteractionController
- ) {
- guard let absoluteRange = selectedAbsoluteRange,
- let page = pageProvider?() else {
- clearSelection(renderView: renderView)
- return
- }
-
- renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
- setInteractionState(isSelecting ? .selecting : .selectionActive)
- onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
- }
-
- private func setInteractionState(_ state: InteractionState) {
- interactionState = state
- }
-
- private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
- guard absoluteRange.location != NSNotFound,
- absoluteRange.length > 0,
- NSMaxRange(absoluteRange) <= page.chapterContent.length else {
- return nil
- }
-
- let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
- guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- return nil
- }
-
- let globalStart = absoluteRange.location
- let globalEnd = absoluteRange.location + absoluteRange.length
- let chapterData = makeChapterData(for: page)
- let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
- return RDEPUBSelection(
- location: location,
- text: selectedText,
- rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
- )
- }
-
- private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData {
- let textChapter = RDEPUBTextChapter(
- chapterIndex: page.chapterIndex,
- spineIndex: page.spineIndex,
- href: page.href,
- title: page.chapterTitle,
- attributedContent: page.chapterContent,
- fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:],
- cfiMap: chapterCFIMapProvider?(),
- pageBreakReasons: [],
- pages: [page]
- )
- return RDEPUBChapterData(
- chapter: textChapter,
- indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift
deleted file mode 100644
index afd465e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import UIKit
-
-extension UIColor {
-
- /// rgba CSS 字符串,如 "rgba(255, 128, 0, 1.000)"
- var rd_cssString: String {
- var red: CGFloat = 0
- var green: CGFloat = 0
- var blue: CGFloat = 0
- var alpha: CGFloat = 0
- getRed(&red, green: &green, blue: &blue, alpha: &alpha)
- return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
- }
-
- /// #RRGGBB hex 字符串
- var rd_hexString: String {
- var red: CGFloat = 0
- var green: CGFloat = 0
- var blue: CGFloat = 0
- var alpha: CGFloat = 0
- getRed(&red, green: &green, blue: &blue, alpha: &alpha)
- return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
- }
-
- /// 判断背景是否为暗色(BT.709 亮度 < 0.4)
- var rd_isDarkBackground: Bool {
- var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
- guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
- return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.4
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift
deleted file mode 100644
index 829a05f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-import UIKit
-
-extension UIColor {
-
- convenience init?(rdHexString: String, alpha: CGFloat) {
- var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
- value = value.replacingOccurrences(of: "#", with: "")
- guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
- self.init(
- red: CGFloat((hex >> 16) & 0xFF) / 255,
- green: CGFloat((hex >> 8) & 0xFF) / 255,
- blue: CGFloat(hex & 0xFF) / 255,
- alpha: alpha
- )
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift
deleted file mode 100644
index 54d1782..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-import UIKit
-
-struct RDEpubReaderPagingController {
-
- struct PageTransitionRequest: Equatable {
- let pageNum: Int
- let animated: Bool
- }
-
- var pendingTransitionRequest: PageTransitionRequest?
-
- var isTransitioning: Bool = false
-
- var didBuildUI = false
-
- static func createPageViewController(isDualPage: Bool) -> UIPageViewController {
- let options: [UIPageViewController.OptionsKey: Any]?
- if isDualPage {
- options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
- } else {
- options = nil
- }
- let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
- pageVC.isDoubleSided = isDualPage
- return pageVC
- }
-
- mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool {
- guard currentDisplayType == .pageCurl, isTransitioning else { return false }
- pendingTransitionRequest = request
- return true
- }
-
- mutating func finishPageCurlTransition() -> PageTransitionRequest? {
- isTransitioning = false
- guard let pending = pendingTransitionRequest else { return nil }
- pendingTransitionRequest = nil
- return pending
- }
-
- mutating func resetPendingState() {
- isTransitioning = false
- pendingTransitionRequest = nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift
deleted file mode 100644
index 51fb53a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift
+++ /dev/null
@@ -1,342 +0,0 @@
-
-import UIKit
-
-protocol RDEpubReaderCachePolicyProviding {
- var shouldAvoidReaderPageCaching: Bool { get }
-}
-
-final class RDEpubReaderPreloadController {
-
- var radius: Int = 2
-
- private let preloadHostView = UIView()
-
- private var preloadedPageViews: [Int: UIView] = [:]
-
- private var pageCurlCachedViews: [Int: UIView] = [:]
-
- private var cacheSignature: CacheSignature?
-
- struct Environment {
-
- let displayType: RDEpubReaderView.DisplayType
-
- let isLandscape: Bool
-
- let pagesPerScreen: Int
-
- let boundsSize: CGSize
-
- let landscapeDualPageEnabled: Bool
-
- let coverPageIndex: Int?
-
- let totalPages: Int
-
- let spreadResolver: RDEpubReaderSpreadResolver
- }
-
- private struct CacheSignature: Equatable {
-
- let displayType: RDEpubReaderView.DisplayType
-
- let isLandscape: Bool
-
- let pagesPerScreen: Int
-
- let boundsSize: CGSize
- }
-
- func setHostFrame(_ frame: CGRect) {
- preloadHostView.frame = frame
- }
-
- func ensureHostView(in parentView: UIView) {
- guard preloadHostView.superview == nil else { return }
- preloadHostView.isHidden = true
- preloadHostView.isUserInteractionEnabled = false
- preloadHostView.clipsToBounds = true
- preloadHostView.frame = parentView.bounds
- preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- parentView.insertSubview(preloadHostView, at: 0)
- }
-
- func initializeSignature(_ environment: Environment) {
- cacheSignature = currentCacheSignature(environment)
- }
-
- func invalidate(environment: Environment) {
- RDEpubReaderTapDebug.log(
- "PreloadController.invalidate",
- "clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())"
- )
- pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
- preloadedPageViews.values.forEach { $0.removeFromSuperview() }
- pageCurlCachedViews.removeAll()
- preloadedPageViews.removeAll()
- cacheSignature = currentCacheSignature(environment)
- }
-
- func pageViewForDisplay(
- pageNum: Int,
- environment: Environment,
- contentViewProvider: (Int, UIView?) -> UIView?
- ) -> UIView {
- let view: UIView
- if let reusableView = detachedReusablePageView(for: pageNum) {
- view = reusableView
- RDEpubReaderTapDebug.log(
- "PreloadController.pageViewForDisplay",
- "cache HIT page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(reusableView))"
- )
- } else {
- view = contentViewProvider(pageNum, nil) ?? UIView()
- RDEpubReaderTapDebug.log(
- "PreloadController.pageViewForDisplay",
- "cache MISS page=\(pageNum) created=\(RDEpubReaderTapDebug.describe(view))"
- )
- }
- if shouldCache(view: view, for: pageNum, environment: environment) {
- pageCurlCachedViews[pageNum] = view
- } else {
- pageCurlCachedViews.removeValue(forKey: pageNum)
- }
- return view
- }
-
- func takePreloadedView(for pageNum: Int) -> UIView? {
- let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
- preloaded?.removeFromSuperview()
- RDEpubReaderTapDebug.log(
- "PreloadController.takePreloadedView",
- "cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(preloaded))"
- )
- return preloaded
- }
-
- func prime(
- around pageNum: Int,
- preferredForward: Bool? = nil,
- parentView: UIView,
- environment: Environment,
- contentViewProvider: (Int, UIView?) -> UIView?
- ) {
- refreshCacheSignatureIfNeeded(environment)
- let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
- let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
- trimCachedPageViews(keeping: keepSet)
- guard !targets.isEmpty else { return }
-
- ensureHostView(in: parentView)
- preloadHostView.frame = parentView.bounds
-
- for targetPage in targets {
- let contentView: UIView
- if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
- contentView = existing
- RDEpubReaderTapDebug.log(
- "PreloadController.prime",
- "cache HIT(preloaded) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(existing))"
- )
- } else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
- contentView = cached
- RDEpubReaderTapDebug.log(
- "PreloadController.prime",
- "cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))"
- )
- } else {
- guard let providedView = contentViewProvider(targetPage, nil) else {
- // 页面提供者明确禁止预加载时不能塞入空白占位缓存,否则实际展示
- // 会直接命中该白页,而不再请求真实内容。
- preloadedPageViews.removeValue(forKey: targetPage)
- pageCurlCachedViews.removeValue(forKey: targetPage)
- continue
- }
- contentView = providedView
- RDEpubReaderTapDebug.log(
- "PreloadController.prime",
- "cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
- )
- }
- let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
- if shouldCacheContentView {
- preloadedPageViews[targetPage] = contentView
- } else {
- preloadedPageViews.removeValue(forKey: targetPage)
- }
- if contentView.superview !== preloadHostView {
- contentView.removeFromSuperview()
- preloadHostView.addSubview(contentView)
- }
- contentView.frame = preloadHostView.bounds
- if !shouldCacheContentView {
- contentView.removeFromSuperview()
- }
- }
- }
-
- private func currentCacheSignature(_ environment: Environment) -> CacheSignature {
- CacheSignature(
- displayType: environment.displayType,
- isLandscape: environment.isLandscape,
- pagesPerScreen: environment.pagesPerScreen,
- boundsSize: environment.boundsSize
- )
- }
-
- private func refreshCacheSignatureIfNeeded(_ environment: Environment) {
- let signature = currentCacheSignature(environment)
- if cacheSignature != signature {
- invalidate(environment: environment)
- } else if cacheSignature == nil {
- cacheSignature = signature
- }
- }
-
- private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool {
- pageNum >= 0
- && pageNum != RDEpubReaderView.blankPageNum
- && pageNum != RDEpubReaderView.blankEndPageNum
- && pageNum < environment.totalPages
- }
-
- private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool {
- guard shouldCachePage(pageNum, environment: environment) else { return false }
- if let policyProvider = view as? RDEpubReaderCachePolicyProviding {
- return !policyProvider.shouldAvoidReaderPageCaching
- }
- return true
- }
-
- private func isDualPage(environment: Environment) -> Bool {
- environment.landscapeDualPageEnabled
- && environment.isLandscape
- && environment.displayType != .verticalScroll
- }
-
- private func spreadStart(for pageNum: Int, environment: Environment) -> Int? {
- guard shouldCachePage(pageNum, environment: environment) else { return nil }
- guard isDualPage(environment: environment) else { return pageNum }
- return environment.spreadResolver.dualPagePair(
- for: pageNum,
- totalPages: environment.totalPages,
- coverPageIndex: environment.coverPageIndex
- ).left
- }
-
- private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set {
- guard shouldCachePage(pageNum, environment: environment) else { return [] }
- guard isDualPage(environment: environment) else { return [pageNum] }
- let pair = environment.spreadResolver.dualPagePair(
- for: pageNum,
- totalPages: environment.totalPages,
- coverPageIndex: environment.coverPageIndex
- )
- var pages: Set = [pair.left]
- if let right = pair.right, shouldCachePage(right, environment: environment) {
- pages.insert(right)
- }
- return pages
- }
-
- private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? {
- guard environment.totalPages > 0,
- let spreadStart = spreadStart(for: pageNum, environment: environment) else {
- return nil
- }
-
- guard isDualPage(environment: environment) else {
- let target = forward ? spreadStart + 1 : spreadStart - 1
- return shouldCachePage(target, environment: environment) ? target : nil
- }
-
- if forward {
- return environment.spreadResolver.adjacentDualPage(
- from: spreadStart,
- totalPages: environment.totalPages,
- coverPageIndex: environment.coverPageIndex,
- forward: true
- )
- }
-
- let pair = environment.spreadResolver.dualPagePair(
- for: spreadStart,
- totalPages: environment.totalPages,
- coverPageIndex: environment.coverPageIndex
- )
- let prevEnd = pair.left - 1
- guard prevEnd >= 0 else { return nil }
- return environment.spreadResolver.dualPagePair(
- for: prevEnd,
- totalPages: environment.totalPages,
- coverPageIndex: environment.coverPageIndex
- ).left
- }
-
- private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set {
- spreadPageNumbers(startingAt: anchorPage, environment: environment)
- }
-
- private func detachedReusablePageView(for pageNum: Int) -> UIView? {
- if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
- preloaded.removeFromSuperview()
- return preloaded
- }
-
- guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else {
- return nil
- }
-
- pageCurlCachedViews.removeValue(forKey: pageNum)
- return cached
- }
-
- private func trimCachedPageViews(keeping pageNumbers: Set) {
- pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
- let keep = pageNumbers.contains(key)
- if !keep {
- value.removeFromSuperview()
- }
- return keep
- }
-
- preloadedPageViews = preloadedPageViews.filter { key, value in
- let keep = pageNumbers.contains(key)
- if !keep {
- value.removeFromSuperview()
- }
- return keep
- }
- }
-
- private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] {
- guard environment.totalPages > 0,
- shouldCachePage(pageNum, environment: environment) else {
- return []
- }
-
- var targets = Set()
-
- var previousAnchor = pageNum
- for _ in 0.. Bool {
- guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
- return pageNum == coverIndex
- }
-
- func dualPagePair(
- for pageNum: Int,
- totalPages: Int,
- coverPageIndex: Int?
- ) -> (left: Int, right: Int?) {
- if let coverIndex = coverPageIndex {
- if pageNum == coverIndex {
- return (coverIndex, nil)
- }
- let adjustedIndex = pageNum - (coverIndex + 1)
- let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2
- let left = pairStart
- let right = pairStart + 1 < totalPages ? pairStart + 1 : nil
- return (left, right)
- }
-
- let left = (pageNum / 2) * 2
- let right = left + 1 < totalPages ? left + 1 : nil
- return (left, right)
- }
-
- func adjacentDualPage(
- from pageNum: Int,
- totalPages: Int,
- coverPageIndex: Int?,
- forward: Bool
- ) -> Int? {
- let pair = dualPagePair(for: pageNum, totalPages: totalPages, coverPageIndex: coverPageIndex)
- if forward {
- let nextStart = (pair.right ?? pair.left) + 1
- return nextStart < totalPages ? nextStart : nil
- }
-
- let prevEnd = pair.left - 1
- guard prevEnd >= 0 else { return nil }
- return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left
- }
-
- func nextPage(
- from currentPage: Int,
- totalPages: Int,
- pagesPerScreen: Int,
- coverPageIndex: Int?,
- forward: Bool
- ) -> Int? {
- if pagesPerScreen > 1 {
- return adjacentDualPage(
- from: currentPage,
- totalPages: totalPages,
- coverPageIndex: coverPageIndex,
- forward: forward
- )
- }
-
- let target = currentPage + (forward ? 1 : -1)
- guard target >= 0, target < totalPages else { return nil }
- return target
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift
deleted file mode 100644
index 01769ca..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-
-import CoreGraphics
-
-struct RDEpubReaderTapRegionHandler {
-
- func resolveTapEvent(
- point: CGPoint,
- viewFrame: CGRect,
- isToolViewVisible: Bool
- ) -> RDEpubReaderView.TapEvent {
- let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
- let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
- let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
-
- if leftFrame.contains(point) {
- return isToolViewVisible ? .center : .left
- }
-
- if centerFrame.contains(point) {
- return .center
- }
-
- if rightFrame.contains(point) {
- return isToolViewVisible ? .center : .right
- }
-
- return .none
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift
deleted file mode 100644
index 3d81284..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-
-import UIKit
-
-class RDEpubReaderContentCell: UICollectionViewCell {
-
- private var _containerView: UIView? = nil
-
- var containerView: UIView? {
- set {
- guard newValue !== _containerView else { return }
- _containerView?.removeFromSuperview()
- _containerView = newValue
- if let containerView = _containerView {
- contentView.addSubview(containerView)
- setNeedsLayout()
- }
- }
- get {
- return _containerView
- }
- }
-
- override func layoutSubviews() {
- super.layoutSubviews()
- _containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
- }
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.content.cell"
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift
deleted file mode 100644
index 544c8b7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift
+++ /dev/null
@@ -1,305 +0,0 @@
-
-import UIKit
-
-public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
-
- func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
-}
-
-@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
-
- func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
-}
-
-public class RDEpubReaderFlowLayout: UICollectionViewFlowLayout {
-
- var displayType: RDEpubReaderView.DisplayType = .horizontalScroll {
- didSet {
- invalidateLayout()
- }
- }
-
- var isLandscapeDualPage: Bool = false {
- didSet {
- if oldValue != isLandscapeDualPage {
- invalidateLayout()
- }
- }
- }
-
- var coverPageIndex: Int? = nil {
- didSet {
- if oldValue != coverPageIndex {
- invalidateLayout()
- }
- }
- }
-
- private var lastPreparedBoundsSize: CGSize = .zero
-
- var pagesPerScreen: Int {
- guard isLandscapeDualPage else { return 1 }
- switch displayType {
- case .horizontalScroll:
- if let cv = collectionView, cv.bounds.width > cv.bounds.height {
- return 2
- }
- return 1
- default:
- return 1
- }
- }
-
- weak var dataSource: RDEpubReaderFlowLayoutDataSoure? = nil
-
- weak var delegate: RDEpubReaderFlowLayoutDelegate? = nil
-
- private var hasCoverPageInDualMode: Bool {
- return pagesPerScreen > 1 && coverPageIndex != nil
- }
-
- private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
- guard let coverIndex = coverPageIndex else {
- let pairIdx = index / 2
- let side = index % 2
- let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth
- return CGRect(x: x, y: 0, width: halfWidth, height: height)
- }
- if index == coverIndex {
- let screensBeforeCover = coverIndex / 2
- let x = CGFloat(screensBeforeCover) * screenWidth
- return CGRect(x: x, y: 0, width: screenWidth, height: height)
- }
- let adjustedIndex = index - (coverIndex + 1)
- let pairIdx = adjustedIndex / 2
- let side = adjustedIndex % 2
- let coverScreens = coverIndex / 2 + 1
- let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
- return CGRect(x: x, y: 0, width: halfWidth, height: height)
- }
-
- private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
- let pps = pagesPerScreen
- guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
- let screenIndex = Int((offset / screenWidth).rounded(.down))
- return screenIndex * pps
- }
- let coverScreens = coverIdx / 2 + 1
- let coverEnd = CGFloat(coverScreens) * screenWidth
- if offset < coverEnd {
- let screenIdx = Int((offset / screenWidth).rounded(.down))
- return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps
- } else {
- let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down))
- return coverIdx + 1 + pairIdx * 2
- }
- }
-
- private var currentPage: Int = 0 {
-
- didSet {
- if let delegate = delegate, delegate.responds(to: #selector(RDEpubReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
- delegate.pageNum(flowLayout: self, pageIndex: currentPage)
- }
- }
- }
-
- init(displayType: RDEpubReaderView.DisplayType) {
- self.displayType = displayType
- super.init()
-
- }
-
- public override func prepare() {
- super.prepare()
- guard let collectionView = self.collectionView else {
- return
- }
- guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
- return
- }
-
- lastPreparedBoundsSize = collectionView.bounds.size
-
- if #available(iOS 11.0, *) {
- collectionView.contentInsetAdjustmentBehavior = .never
- } else {
- collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false
- }
- collectionView.showsVerticalScrollIndicator = false
- collectionView.showsHorizontalScrollIndicator = false
- minimumLineSpacing = 0
- minimumInteritemSpacing = 0
- switch self.displayType {
- case .horizontalScroll:
- scrollDirection = .horizontal
- let columns = CGFloat(pagesPerScreen)
- itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height)
- collectionView.isPagingEnabled = true
- case .verticalScroll:
- itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
- scrollDirection = .vertical
- collectionView.isPagingEnabled = false
- collectionView.showsVerticalScrollIndicator = true
-
- default: break
- }
-
- }
-
- public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
- if newBounds.size != lastPreparedBoundsSize {
- return true
- }
- switch displayType {
- case .verticalScroll:
- return true
- case .horizontalScroll:
- return true
- default:
- return false
- }
- }
-
- public override var collectionViewContentSize: CGSize {
- var size = super.collectionViewContentSize
- guard let collectionView = collectionView else {
- return size
- }
- if displayType == .verticalScroll {
- let totalHeight = [Int](0.. [UICollectionViewLayoutAttributes]? {
- var attributes = super.layoutAttributesForElements(in: rect)
- guard let collectionView = collectionView else {
- return attributes
- }
-
- if self.displayType == .verticalScroll {
- let rowCount = collectionView.numberOfItems(inSection: 0)
- var totalHeight: CGFloat = 0
- for index in 0.. collectionView.contentOffset.y {
- self.currentPage = index
- break
- }
- }
- (attributes ?? []).forEach({ attr in
- let indexPath = attr.indexPath
- let lastRow = max(0, indexPath.row - 1)
- let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height
- var lastTotalHeight: CGFloat = 0
- if indexPath.row > 0 {
- lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in
- result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height)
- }
- }
- if let cell = collectionView.cellForItem(at: indexPath) {
- cell.layer.shadowOpacity = 0
- }
- attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
- })
- }
-
- if self.displayType == .horizontalScroll {
- let pps = pagesPerScreen
-
- if hasCoverPageInDualMode {
- let screenWidth = collectionView.frame.width
- let halfWidth = screenWidth / 2.0
- let rows = collectionView.numberOfItems(inSection: 0)
-
- self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
-
- var attrs = [UICollectionViewLayoutAttributes]()
- for index in 0.. 1 {
- let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps
- self.currentPage = currentPage
- (attributes ?? []).forEach({ attr in
- if let cell = collectionView.cellForItem(at: attr.indexPath) {
- cell.layer.shadowOpacity = 0
- }
- })
- } else {
- let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down))
- self.currentPage = currentPage
- (attributes ?? []).forEach({ attr in
- if let cell = collectionView.cellForItem(at: attr.indexPath) {
- cell.layer.shadowOpacity = 0
- }
- })
- }
- }
-
- return attributes
- }
-
- func currentContentOffset(count: Int) -> CGPoint {
- guard let collectionView = collectionView else {
- return .zero
- }
- guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
- return .zero
- }
- let safeCount = max(0, count)
- switch displayType {
- case .verticalScroll:
- guard safeCount > 0 else { return .zero }
- let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in
- result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height)
- }
- return CGPoint(x: 0, y: totalHeight)
- default:
- let pps = pagesPerScreen
- let screenWidth = collectionView.frame.width
- if hasCoverPageInDualMode, let coverIdx = coverPageIndex {
- if safeCount == coverIdx {
- let screensBeforeCover = coverIdx / 2
- return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0)
- }
- if safeCount < coverIdx {
- return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0)
- }
- let coverScreens = coverIdx / 2 + 1
- let adjustedIndex = safeCount - (coverIdx + 1)
- let pairIndex = adjustedIndex / 2
- return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0)
- } else if pps > 1 {
- let screenIndex = safeCount / pps
- return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0)
- } else {
- return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0)
- }
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift
deleted file mode 100644
index cb5ce0e..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-
-import UIKit
-
-class RDEpubReaderGestureController: UIViewController {
-
- var topToolView: UIView?
-
- var bottomToolView: UIView?
-
- init(topToolView: UIView?, bottomToolView: UIView?) {
- self.topToolView = topToolView
- self.bottomToolView = bottomToolView
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
-
- }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift
deleted file mode 100644
index 015256a..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift
+++ /dev/null
@@ -1,50 +0,0 @@
-
-import UIKit
-
-class RDEpubReaderPageChildViewController: UIViewController {
-
- private let contentContainerView = UIView()
-
- var contentView: UIView? {
- didSet {
- guard isViewLoaded else { return }
- installContentView()
- }
- }
-
- var pageNum: Int = 0
-
- init(contentView: UIView?, pageNum: Int = 0) {
- self.contentView = contentView
- self.pageNum = pageNum
- super.init(nibName: nil, bundle: nil)
- }
-
- override func loadView() {
- view = UIView()
- view.backgroundColor = .clear
- contentContainerView.frame = view.bounds
- contentContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- contentContainerView.backgroundColor = .clear
- view.addSubview(contentContainerView)
- installContentView()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- override func viewDidLoad() {
- super.viewDidLoad()
-
- }
-
- private func installContentView() {
- contentContainerView.subviews.forEach { $0.removeFromSuperview() }
- guard let contentView else { return }
- contentView.removeFromSuperview()
- contentView.frame = contentContainerView.bounds
- contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- contentContainerView.addSubview(contentView)
- }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift
deleted file mode 100644
index f9ff05c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-
-import UIKit
-
-extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDelegate, RDEpubReaderFlowLayoutDataSoure {
-
- public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
-
- if let identifer = pageReuseIdentifier(for: indexPath.row) {
- let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDEpubReaderContentCell
- let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
- let reusableView = cell.containerView ?? preloadedView
- cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
-
- if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
- cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
- } else {
- cell.contentView.transform = .identity
- }
- return cell
- }
-
- let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
-
- return cell
- }
-
- public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
- return numberOfPages()
- }
-
- public func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) {
- if currentPage >= 0, currentPage != pageIndex {
- predictedPageDirection = pageIndex >= currentPage
- }
- currentPage = pageIndex
- primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
- }
-
- public func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? {
- return nil
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift
deleted file mode 100644
index a84e621..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-
-import UIKit
-
-extension RDEpubReaderView {
-
- public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
- contentViews[identifier] = contentView
- collectionView.register(RDEpubReaderContentCell.self, forCellWithReuseIdentifier: identifier)
- }
-
- public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
- if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDEpubReaderContentCell, let containerView = cell.containerView {
- return containerView
- }
- let contentViewClass = contentViews[identifier]
- assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)")
- let contentView = contentViewClass!.init()
- return contentView
- }
-
- public func pageContentView(pageNum: Int) -> UIView? {
- if currentDisplayType == .pageCurl {
- return (self.pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.contentView
- } else {
- let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDEpubReaderContentCell
- return cell?.containerView
- }
- }
-
- public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize {
- let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil)
- if let targetPage,
- let contentView = pageContentView(pageNum: targetPage),
- contentView.bounds.width > 0,
- contentView.bounds.height > 0 {
- return contentView.bounds.size
- }
-
- if currentDisplayType == .pageCurl {
- if let childViewController = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController,
- childViewController.view.bounds.width > 0,
- childViewController.view.bounds.height > 0 {
- return childViewController.view.bounds.size
- }
- if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 {
- return pageViewController.view.bounds.size
- }
- return bounds.size
- }
-
- let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size
- guard containerBounds.width > 0, containerBounds.height > 0 else {
- return bounds.size
- }
-
- switch currentDisplayType {
- case .horizontalScroll:
- return CGSize(
- width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)),
- height: containerBounds.height
- )
- case .verticalScroll:
- return containerBounds
- case .pageCurl:
- return bounds.size
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift
deleted file mode 100644
index ae910c8..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift
+++ /dev/null
@@ -1,123 +0,0 @@
-
-import UIKit
-
-extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
-
- private func makeSinglePageChildVC(for pageNum: Int) -> RDEpubReaderPageChildViewController {
- if pageNum == RDEpubReaderView.blankPageNum || pageNum == RDEpubReaderView.blankEndPageNum {
- return RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
- }
- let contentView = pageViewForDisplay(pageNum: pageNum)
- return RDEpubReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
- }
-
- private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
- let totalPages = numberOfPages()
-
- if pageNum == RDEpubReaderView.blankEndPageNum {
- return nil
- }
-
- if isDualPage, let coverIndex = coverPageIndex {
- if pageNum == coverIndex {
- return RDEpubReaderView.blankPageNum
- }
- if pageNum == RDEpubReaderView.blankPageNum {
- let firstContent = coverIndex + 1
- return firstContent < totalPages ? firstContent : nil
- }
- }
-
- let next = pageNum + 1
- if next < totalPages {
- return next
- }
-
- if isDualPage {
- if let coverIndex = coverPageIndex {
- let adjustedIndex = pageNum - (coverIndex + 1)
- if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
- return RDEpubReaderView.blankEndPageNum
- }
- } else {
- if pageNum % 2 == 0 {
- return RDEpubReaderView.blankEndPageNum
- }
- }
- }
- return nil
- }
-
- private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
- if pageNum == RDEpubReaderView.blankEndPageNum {
- let totalPages = numberOfPages()
- return totalPages > 0 ? totalPages - 1 : nil
- }
- if isDualPage, let coverIndex = coverPageIndex {
- if pageNum == RDEpubReaderView.blankPageNum {
- return coverIndex
- }
- if pageNum == coverIndex + 1 {
- return RDEpubReaderView.blankPageNum
- }
- }
- let prev = pageNum - 1
- return prev >= 0 ? prev : nil
- }
-
- public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
- guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil }
- let isDualPage = landscapeDualPageEnabled && isLandscape
- let isRTL = pageDirection == .rightToLeft
-
- let targetNum = isRTL
- ? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
- : prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
-
- guard let num = targetNum else { return nil }
- let targetVC = makeSinglePageChildVC(for: num)
- willPreviousTransitionToViewController = targetVC
- return targetVC
- }
-
- public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
- guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil }
- let isDualPage = landscapeDualPageEnabled && isLandscape
- let isRTL = pageDirection == .rightToLeft
-
- let targetNum = isRTL
- ? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
- : nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
-
- guard let num = targetNum else { return nil }
- let targetVC = makeSinglePageChildVC(for: num)
- willNextTransitionToViewController = targetVC
- return targetVC
- }
-
- public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
- let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1
- if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
- let pn = firstVC.pageNum
- if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
- currentPage = pn
- primePageCache(around: pn, preferredForward: predictedPageDirection)
- }
- }
-
- predictedPageDirection = nil
- finishPageCurlTransition()
- }
-
- public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
- pagingController.isTransitioning = true
- willTransitionToViewController = pendingViewControllers.first
- if let target = pendingViewControllers.first as? RDEpubReaderPageChildViewController,
- target.pageNum != RDEpubReaderView.blankPageNum,
- target.pageNum != RDEpubReaderView.blankEndPageNum {
- predictedPageDirection = target.pageNum >= currentPage
- primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
- } else {
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift
deleted file mode 100644
index 881c61c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift
+++ /dev/null
@@ -1,136 +0,0 @@
-
-import UIKit
-
-extension RDEpubReaderView {
-
- func tapCenter() {
- refreshToolViewsFromProviderIfNeeded()
- isShowToolView = !isShowToolView
- RDEpubReaderTapDebug.log(
- "ReaderView.tapCenter",
- "toggle toolView visible=\(isShowToolView) top=\(RDEpubReaderTapDebug.describe(topToolView)) bottom=\(RDEpubReaderTapDebug.describe(bottomToolView))"
- )
- if isShowToolView {
- if let topToolView = topToolView {
- installToolViewIfNeeded(topToolView, position: .top)
- layoutIfNeeded()
- topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
- UIView.animate(withDuration: toolViewAnimationDuration) {
- topToolView.transform = .identity
- }
- }
-
- if let bottomToolView = bottomToolView {
- installToolViewIfNeeded(bottomToolView, position: .bottom)
- layoutIfNeeded()
- bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
- UIView.animate(withDuration: toolViewAnimationDuration) {
- bottomToolView.transform = .identity
- }
- }
-
- collectionView.isUserInteractionEnabled = false
- pageViewController.view.isUserInteractionEnabled = false
- RDEpubReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible")
- } else {
- if let topToolView = topToolView {
- UIView.animate(withDuration: toolViewAnimationDuration, animations: {
- topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
- }) { _ in
- topToolView.removeFromSuperview()
- }
- }
-
- if let bottomToolView = bottomToolView {
- UIView.animate(withDuration: toolViewAnimationDuration, animations: {
- bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
- }) { _ in
- bottomToolView.removeFromSuperview()
- }
- }
-
- collectionView.isUserInteractionEnabled = true
- pageViewController.view.isUserInteractionEnabled = true
- RDEpubReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView")
- }
- onToolViewVisibilityChanged?(isShowToolView)
- }
-
- func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
- if let hitView, hitView === toolView || hitView.isDescendant(of: toolView) {
- return true
- }
-
- if toolView.frame.contains(point) {
- // For search bar views, only consider the hit inside the visible
- // panel area. Taps on the dimmed scrim should pass through to
- // the reader content to allow chrome toggling. Touches that
- // resolved to a search bar subview (e.g. the accessibility-only
- // navigation buttons outside the panel) are already claimed by
- // the descendant check above.
- if let searchBarView = toolView as? RDEPUBReaderSearchBarView {
- let panelPoint = convert(point, to: searchBarView.panelView)
- if !searchBarView.panelView.point(inside: panelPoint, with: nil) {
- return false
- }
- }
- return true
- }
-
- return false
- }
-
- enum ToolViewPosition {
-
- case top
-
- case bottom
- }
-
- func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
- guard toolView.superview !== self else { return }
-
- toolView.removeFromSuperview()
- addSubview(toolView)
- toolView.translatesAutoresizingMaskIntoConstraints = false
-
- let heightConstraint: NSLayoutConstraint
- switch position {
- case .top:
- topToolViewHeightConstraint?.isActive = false
- heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top))
- topToolViewHeightConstraint = heightConstraint
- NSLayoutConstraint.activate([
- toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
- toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
- toolView.topAnchor.constraint(equalTo: topAnchor),
- heightConstraint
- ])
- case .bottom:
- bottomToolViewHeightConstraint?.isActive = false
- heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom))
- bottomToolViewHeightConstraint = heightConstraint
- NSLayoutConstraint.activate([
- toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
- toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
- toolView.bottomAnchor.constraint(equalTo: bottomAnchor),
- heightConstraint
- ])
- }
- }
-
- func updateToolViewHeightConstraintsIfNeeded() {
- topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
- bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
- }
-
- private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat {
- let contentHeight: CGFloat = 52
- switch position {
- case .top:
- return safeAreaInsets.top + contentHeight
- case .bottom:
- return safeAreaInsets.bottom + contentHeight
- }
- }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift
deleted file mode 100644
index 6949089..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift
+++ /dev/null
@@ -1,939 +0,0 @@
-
-import UIKit
-
-enum RDEpubReaderTapDebug {
-
- private static let enabledArguments: Set = [
- "--demo-tap-debug",
- "--demo-reader-interaction-debug"
- ]
-
- static var isEnabled: Bool {
-#if DEBUG
- return true
-#else
- let arguments = ProcessInfo.processInfo.arguments
- if arguments.contains(where: { enabledArguments.contains($0) }) {
- return true
- }
-
- let environment = ProcessInfo.processInfo.environment
- return environment["RDEpubReader_TAP_DEBUG"] == "1"
-#endif
- }
-
- static func log(_ scope: String, _ message: String) {
- guard isEnabled else { return }
- let threadRole = Thread.isMainThread ? "main" : "bg"
- let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
- print("[RDEpubReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
- }
-
- static func describe(_ point: CGPoint) -> String {
- "(\(format(point.x)), \(format(point.y)))"
- }
-
- static func describe(_ rect: CGRect) -> String {
- "(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))"
- }
-
- static func describe(_ view: UIView?) -> String {
- guard let view else { return "nil" }
- let identifier = view.accessibilityIdentifier ?? "nil"
- return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))"
- }
-
- private static func format(_ value: CGFloat) -> String {
- String(format: "%.1f", value)
- }
-}
-
-public class RDEpubReaderView: UIView {
-
- enum TapEvent {
-
- case none
-
- case left
-
- case center
-
- case right
- }
-
- lazy var pageViewController: UIPageViewController = {
- let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
- pageVC.delegate = self
- pageVC.dataSource = self
- return pageVC
- }()
-
- lazy var layout: RDEpubReaderFlowLayout = {
- let layout = RDEpubReaderFlowLayout(displayType: .horizontalScroll)
- layout.dataSource = self
- layout.delegate = self
- return layout
- }()
-
- lazy var collectionView: UICollectionView = {
-
- let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
- collectionView.backgroundColor = UIColor.clear
- collectionView.accessibilityIdentifier = "epub.reader.paging"
- return collectionView
- }()
-
- private let spreadResolver = RDEpubReaderSpreadResolver()
-
- private let tapRegionHandler = RDEpubReaderTapRegionHandler()
-
- let preloadController = RDEpubReaderPreloadController()
-
- var pagingController = RDEpubReaderPagingController()
-
- public var currentPage: Int = -1 {
- didSet {
- if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
- delegate.pageNum(readerView: self, pageNum: currentPage)
- }
- }
- }
-
- private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
- let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
- tap.delegate = self
- return tap
- }()
-
- private var tapEvent: TapEvent = .none {
- didSet {
- let isRTL = pageDirection == .rightToLeft
- switch tapEvent {
- case .left:
- if currentDisplayType != .pageCurl {
- if isRTL { goNextPage() } else { goPreviousPage() }
- }
- case .right:
- if currentDisplayType != .pageCurl {
- if isRTL { goPreviousPage() } else { goNextPage() }
- }
- case .center:
- tapCenter()
- default:
- break
- }
- }
- }
-
- private func goNextPage() {
- let totalPages = numberOfPages()
- if let target = spreadResolver.nextPage(
- from: currentPage,
- totalPages: totalPages,
- pagesPerScreen: pagesPerScreen,
- coverPageIndex: coverPageIndex,
- forward: true
- ) {
- transitionToPage(pageNum: target, animated: true)
- }
- }
-
- private func goPreviousPage() {
- let totalPages = numberOfPages()
- if let target = spreadResolver.nextPage(
- from: currentPage,
- totalPages: totalPages,
- pagesPerScreen: pagesPerScreen,
- coverPageIndex: coverPageIndex,
- forward: false
- ) {
- transitionToPage(pageNum: target, animated: true)
- }
- }
-
- private let legacyDataSourceAdapter = RDEpubReaderLegacyDataSourceAdapter()
-
- public weak var dataSource: RDEpubReaderDataSource? {
- didSet {
- legacyDataSourceAdapter.dataSource = dataSource
- }
- }
-
- public weak var pageProvider: RDEpubReaderPageProvider?
-
- public weak var delegate: RDEpubReaderDelegate? = nil
-
- public var currentDisplayType: RDEpubReaderView.DisplayType = .pageCurl
-
- public var toolViewAnimationDuration: TimeInterval = 0.3
-
- var onToolViewVisibilityChanged: ((Bool) -> Void)?
-
- var searchBarView: UIView?
-
- public var landscapeDualPageEnabled: Bool = false
-
- public var pageDirection: RDEpubReaderView.PageDirection = .leftToRight
-
- public var coverPageIndex: Int? = nil
-
- private var resolvedPageProvider: RDEpubReaderPageProvider? {
- pageProvider ?? legacyDataSourceAdapter
- }
-
- func numberOfPages() -> Int {
- resolvedPageProvider?.numberOfPages(in: self) ?? 0
- }
-
- func pageReuseIdentifier(for pageNum: Int) -> String? {
- resolvedPageProvider?.pageIdentifier?(in: self, index: pageNum)
- }
-
- func contentViewForPage(_ pageNum: Int, reusableView: UIView?) -> UIView {
- resolvedPageProvider?.readerView(self, viewForPageAt: pageNum, reusableView: reusableView) ?? UIView()
- }
-
- private func resolvedTopChromeView() -> UIView? {
- resolvedPageProvider?.readerViewTopChrome?(self)
- }
-
- private func resolvedBottomChromeView() -> UIView? {
- resolvedPageProvider?.readerViewBottomChrome?(self)
- }
-
- func refreshToolViewsFromProviderIfNeeded() {
- if topToolView == nil {
- topToolView = resolvedTopChromeView()
- }
- if bottomToolView == nil {
- bottomToolView = resolvedBottomChromeView()
- }
- }
-
- private var hasCoverPage: Bool {
- return coverPageIndex != nil
- }
-
- var isLandscape: Bool {
- return bounds.width > bounds.height
- }
-
- public var pagesPerScreen: Int {
- if !landscapeDualPageEnabled { return 1 }
- if currentDisplayType == .verticalScroll { return 1 }
- return isLandscape ? 2 : 1
- }
-
- public func isFullScreenPage(_ pageNum: Int) -> Bool {
- spreadResolver.isFullScreenPage(
- pageNum,
- landscapeDualPageEnabled: landscapeDualPageEnabled,
- isLandscape: isLandscape,
- coverPageIndex: coverPageIndex
- )
- }
-
- private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
- let totalPages = numberOfPages()
- return spreadResolver.dualPagePair(
- for: pageNum,
- totalPages: totalPages,
- coverPageIndex: coverPageIndex
- )
- }
-
- private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
- let totalPages = numberOfPages()
- return spreadResolver.adjacentDualPage(
- from: pageNum,
- totalPages: totalPages,
- coverPageIndex: coverPageIndex,
- forward: forward
- )
- }
-
- private func clampedPageNumber(_ pageNum: Int) -> Int? {
- let totalPages = numberOfPages()
- guard totalPages > 0 else { return nil }
- return min(max(pageNum, 0), totalPages - 1)
- }
-
- private var previousIsLandscape: Bool?
-
- var contentViews = [String : UIView.Type]()
-
- var willPreviousTransitionToViewController: UIViewController? = nil
-
- var willNextTransitionToViewController: UIViewController? = nil
-
- var willTransitionToViewController: UIViewController? = nil
-
- var topToolView: UIView?
-
- var bottomToolView: UIView?
-
- var topToolViewHeightConstraint: NSLayoutConstraint?
-
- var bottomToolViewHeightConstraint: NSLayoutConstraint?
-
- var isShowToolView: Bool = false
-
- private var isTransitioning: Bool {
- get { pagingController.isTransitioning }
- set { pagingController.isTransitioning = newValue }
- }
-
- private var didBuildUI: Bool {
- get { pagingController.didBuildUI }
- set { pagingController.didBuildUI = newValue }
- }
-
- var predictedPageDirection: Bool?
-
- public var preloadRadius: Int {
- get { preloadController.radius }
- set { preloadController.radius = newValue }
- }
-
- static let blankPageNum = Int.max
-
- static let blankEndPageNum = Int.max - 1
-
- private typealias PageTransitionRequest = RDEpubReaderPagingController.PageTransitionRequest
-
- private let registeredSelectionLongPressRecognizers = NSHashTable.weakObjects()
-
- private let selectionTapSuppressedContentViews = NSHashTable.weakObjects()
-
- private let selectionPagingSuppressedContentViews = NSHashTable.weakObjects()
-
- private var isPagingInteractionSuppressed = false
-
- private var pendingTransitionRequest: PageTransitionRequest? {
- get { pagingController.pendingTransitionRequest }
- set { pagingController.pendingTransitionRequest = newValue }
- }
-
- var isPageCurlTransitioning: Bool {
- currentDisplayType == .pageCurl && pagingController.isTransitioning
- }
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- accessibilityIdentifier = "epub.reader.content"
- isAccessibilityElement = false
- }
-
- public override func layoutSubviews() {
- super.layoutSubviews()
- guard bounds.width > 0, bounds.height > 0 else { return }
- preloadController.setHostFrame(bounds)
- updateToolViewHeightConstraintsIfNeeded()
- let nowLandscape = isLandscape
- if let prev = previousIsLandscape, prev != nowLandscape {
- previousIsLandscape = nowLandscape
-
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.orientationChanged(isNowLandscape: nowLandscape)
- }
- } else if previousIsLandscape == nil {
- previousIsLandscape = nowLandscape
- layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
- }
- }
-
- private func orientationChanged(isNowLandscape: Bool) {
- let savedPage = max(0, currentPage)
-
- delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
-
- layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
- layout.coverPageIndex = coverPageIndex
-
- switch currentDisplayType {
- case .pageCurl:
-
- rebuildPageViewController()
- transitionToPage(pageNum: savedPage)
- primePageCache(around: savedPage, preferredForward: predictedPageDirection)
- default:
-
- UIView.performWithoutAnimation {
-
- collectionView.collectionViewLayout.invalidateLayout()
- collectionView.reloadData()
- collectionView.layoutIfNeeded()
-
- let totalPages = numberOfPages()
- let safePage = min(savedPage, max(0, totalPages - 1))
- let targetOffset = layout.currentContentOffset(count: safePage)
- collectionView.setContentOffset(targetOffset, animated: false)
- primePageCache(around: safePage, preferredForward: predictedPageDirection)
- }
- }
- }
-
- private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
- let options: [UIPageViewController.OptionsKey: Any]?
- if isDualPage {
- options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
- } else {
- options = nil
- }
- let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
- pageVC.delegate = self
- pageVC.dataSource = self
- pageVC.isDoubleSided = isDualPage
- return pageVC
- }
-
- private func rebuildPageViewController() {
- detachPageViewControllerIfNeeded()
-
- let isDualPage = landscapeDualPageEnabled && isLandscape
- let pageVC = createPageViewController(isDualPage: isDualPage)
- pageViewController = pageVC
- attachPageViewControllerIfNeeded()
- }
-
- private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
- guard currentDisplayType == .pageCurl else { return false }
- let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
- guard let viewControllers = pageVC.viewControllers,
- viewControllers.count == expectedCount else {
- return true
- }
-
- let childViewControllers = viewControllers.compactMap { $0 as? RDEpubReaderPageChildViewController }
- guard childViewControllers.count == expectedCount else {
- return true
- }
-
- if expectedCount == 1 {
- return childViewControllers.first?.pageNum != currentPage
- }
-
- let expectedPair = dualPagePair(for: currentPage)
- let expectedRightPage = expectedPair.right
- ?? (isFullScreenPage(expectedPair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum)
- return childViewControllers[0].pageNum != expectedPair.left
- || childViewControllers[1].pageNum != expectedRightPage
- }
-
- private func patchPageViewControllerFault() {
- guard currentPage >= 0 else { return }
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- self.pagingController.resetPendingState()
- self.willPreviousTransitionToViewController = nil
- self.willNextTransitionToViewController = nil
- self.willTransitionToViewController = nil
- self.invalidatePageCaches()
- self.rebuildPageViewController()
- self.transitionToPage(pageNum: self.currentPage, animated: false)
- }
- }
-
- private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
- pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
- }
-
- func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
- willPreviousTransitionToViewController = nil
- willNextTransitionToViewController = nil
- willTransitionToViewController = nil
-
- if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) {
- patchPageViewControllerFault()
- return
- }
-
- if let pending = pagingController.finishPageCurlTransition() {
- transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
- }
-
- updatePagingInteractionSuppression()
- }
-
- private var preloadEnvironment: RDEpubReaderPreloadController.Environment {
- RDEpubReaderPreloadController.Environment(
- displayType: currentDisplayType,
- isLandscape: isLandscape,
- pagesPerScreen: pagesPerScreen,
- boundsSize: bounds.size,
- landscapeDualPageEnabled: landscapeDualPageEnabled,
- coverPageIndex: coverPageIndex,
- totalPages: numberOfPages(),
- spreadResolver: spreadResolver
- )
- }
-
- private func invalidatePageCaches() {
- preloadController.invalidate(environment: preloadEnvironment)
- }
-
- func pageViewForDisplay(pageNum: Int) -> UIView {
- preloadController.pageViewForDisplay(
- pageNum: pageNum,
- environment: preloadEnvironment,
- contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:)
- )
- }
-
- func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
- preloadController.prime(
- around: pageNum,
- preferredForward: preferredForward,
- parentView: self,
- environment: preloadEnvironment,
- contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
- )
- }
-
- private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
- contentViewForPage(pageNum, reusableView: reusableView)
- }
-
- private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
- let allowsPreloading = resolvedPageProvider?
- .readerView?(self, shouldPreloadPageAt: pageNum) ?? true
- guard allowsPreloading else { return nil }
- return contentViewForPage(pageNum, reusableView: reusableView)
- }
-
- public override func didMoveToSuperview() {
- super.didMoveToSuperview()
-
- if superview != nil {
- makeUI()
- }
- }
-
- private func attachPageViewControllerIfNeeded() {
- guard let parentViewController = self.ss_superViewController else { return }
-
- var didAddToParent = false
- if pageViewController.parent !== parentViewController {
- if pageViewController.parent != nil {
- pageViewController.willMove(toParent: nil)
- pageViewController.view.removeFromSuperview()
- pageViewController.removeFromParent()
- }
- parentViewController.addChild(pageViewController)
- didAddToParent = true
- }
-
- pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
- pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- if pageViewController.view.superview !== self {
- insertSubview(pageViewController.view, at: 0)
- }
-
- if didAddToParent {
- pageViewController.didMove(toParent: parentViewController)
- }
-
- if currentDisplayType == .pageCurl, !isTransitioning, pendingTransitionRequest != nil {
- finishPageCurlTransition(repairFaultIfNeeded: false)
- }
- }
-
- private func detachPageViewControllerIfNeeded() {
- if pageViewController.parent != nil {
- pageViewController.willMove(toParent: nil)
- }
- pageViewController.view.removeFromSuperview()
- if pageViewController.parent != nil {
- pageViewController.removeFromParent()
- }
- }
-
- private func makeUI() {
- guard !didBuildUI else {
- if currentDisplayType == .pageCurl {
- attachPageViewControllerIfNeeded()
- }
- return
- }
- didBuildUI = true
-
- collectionView.dataSource = self
- collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self))
- collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
- collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
-
- if currentDisplayType == .pageCurl {
- attachPageViewControllerIfNeeded()
- }
- preloadController.ensureHostView(in: self)
- preloadController.initializeSignature(preloadEnvironment)
-
- addGestureRecognizer(tapGestureRecognizer)
-
- tapGestureRecognizer.cancelsTouchesInView = false
-
- }
-
- @objc private func tapAction(tap: UITapGestureRecognizer) {
- let point = tap.location(in: tap.view)
- let hitView = hitTest(point, with: nil)
- RDEpubReaderTapDebug.log(
- "ReaderView.tapAction",
- "received point=\(RDEpubReaderTapDebug.describe(point)) hitView=\(RDEpubReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)"
- )
- if containsTextContentView(in: hitView) {
- RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view")
- return
- }
- if shouldSuppressChromeToggle(for: hitView, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed")
- return
- }
- handleResolvedTap(at: point, hitView: hitView, in: tap.view)
- }
-
- private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool {
- if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
- RDEpubReaderTapDebug.log(
- "ReaderView.suppressChromeToggle",
- "suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
- )
- return true
- }
- var currentView = hitView
- while let view = currentView {
- if let textContentView = view as? RDEPUBTextContentView {
- let localPoint = convert(point, to: textContentView)
- let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint)
- if shouldSuppress {
- RDEpubReaderTapDebug.log(
- "ReaderView.suppressChromeToggle",
- "suppressed by text content view=\(RDEpubReaderTapDebug.describe(textContentView)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
- )
- }
- return shouldSuppress
- }
- currentView = view.superview
- }
- return false
- }
-
- func registerSelectionGestureDependenciesIfNeeded(for contentView: UIView) {
- guard let textContentView = contentView as? RDEPUBTextContentView else { return }
- let longPressGesture = textContentView.selectionLongPressGestureRecognizer
- if registeredSelectionLongPressRecognizers.allObjects.contains(where: { $0 === longPressGesture }) {
- return
- }
- tapGestureRecognizer.require(toFail: longPressGesture)
- registeredSelectionLongPressRecognizers.add(longPressGesture)
- }
-
- func updateSelectionTapSuppression(for contentView: UIView, isSuppressed: Bool) {
- if isSuppressed {
- if selectionTapSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
- selectionTapSuppressedContentViews.add(contentView)
- }
- } else {
- selectionTapSuppressedContentViews.remove(contentView)
- }
- RDEpubReaderTapDebug.log(
- "ReaderView.selectionTapSuppression",
- "contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)"
- )
- }
-
- func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) {
- if isSuppressed {
- if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
- selectionPagingSuppressedContentViews.add(contentView)
- }
- } else {
- selectionPagingSuppressedContentViews.remove(contentView)
- }
- RDEpubReaderTapDebug.log(
- "ReaderView.selectionPagingSuppression",
- "contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)"
- )
- updatePagingInteractionSuppression()
- }
-
- func handleContentTap(at point: CGPoint, in sourceView: UIView) {
- let localPoint = sourceView.convert(point, to: self)
- RDEpubReaderTapDebug.log(
- "ReaderView.handleContentTap",
- "forwarded from sourceView=\(RDEpubReaderTapDebug.describe(sourceView)) sourcePoint=\(RDEpubReaderTapDebug.describe(point)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
- )
- handleResolvedTap(at: localPoint, hitView: nil, in: self)
- }
-
- private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) {
- if isShowToolView {
- if let top = topToolView, isHitView(hitView, inside: top, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view")
- return
- }
- if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view")
- return
- }
- }
- guard let viewFrame = tapView?.frame else { return }
- tapEvent = tapRegionHandler.resolveTapEvent(
- point: point,
- viewFrame: viewFrame,
- isToolViewVisible: isShowToolView
- )
- RDEpubReaderTapDebug.log(
- "ReaderView.handleResolvedTap",
- "resolved tapEvent=\(tapEvent) point=\(RDEpubReaderTapDebug.describe(point)) viewFrame=\(RDEpubReaderTapDebug.describe(viewFrame))"
- )
- }
-
- private func containsTextContentView(in hitView: UIView?) -> Bool {
- var currentView = hitView
- while let view = currentView {
- if view is RDEPUBTextContentView {
- return true
- }
- currentView = view.superview
- }
- return false
- }
-
- private func updatePagingInteractionSuppression() {
- let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
- guard shouldSuppress != isPagingInteractionSuppressed else { return }
- isPagingInteractionSuppressed = shouldSuppress
- RDEpubReaderTapDebug.log(
- "ReaderView.pagingSuppression",
- "updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)"
- )
- setPagingInteractionEnabled(!shouldSuppress)
- }
-
- private func activePagingSuppressionContentViews() -> [UIView] {
- selectionPagingSuppressedContentViews.allObjects.filter { contentView in
- guard contentView.window != nil,
- contentView.isDescendant(of: self),
- contentView.bounds.isEmpty == false else {
- return false
- }
- let frameInReader = contentView.convert(contentView.bounds, to: self)
- return frameInReader.intersects(bounds)
- }
- }
-
- private func setPagingInteractionEnabled(_ isEnabled: Bool) {
- collectionView.isScrollEnabled = isEnabled
- pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
- }
-
- public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
- let previousDisplayType = currentDisplayType
- self.currentDisplayType = displayType
- if currentPage == -1 {
- currentPage = 0
- }
- if previousDisplayType != displayType {
- invalidatePageCaches()
- }
-
- layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape
- layout.coverPageIndex = coverPageIndex
- switch displayType {
- case .pageCurl:
- self.collectionView.removeFromSuperview()
- self.collectionView.transform = .identity
- attachPageViewControllerIfNeeded()
- rebuildPageViewController()
- transitionToPage(pageNum: currentPage)
- primePageCache(around: currentPage, preferredForward: predictedPageDirection)
- default:
- detachPageViewControllerIfNeeded()
-
- if pageDirection == .rightToLeft && displayType != .verticalScroll {
- collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
- } else {
- collectionView.transform = .identity
- }
-
- collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
- insertSubview(self.collectionView, at: 0)
- layout.displayType = displayType
- transitionToPage(pageNum: currentPage)
- primePageCache(around: currentPage, preferredForward: predictedPageDirection)
- }
- }
-
- public func transitionToPage(pageNum: Int, animated: Bool = false) {
- guard let safePageNum = clampedPageNumber(pageNum) else { return }
- switch currentDisplayType {
- case .pageCurl:
- if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
- RDEpubReaderTapDebug.log(
- "ReaderView.transitionToPage",
- "skip same page page=\(safePageNum) animated=\(animated)"
- )
- return
- }
-
- let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
- if shouldQueuePageTransition(request) {
- return
- }
-
- let isDualPage = landscapeDualPageEnabled && isLandscape
-
- let direction: UIPageViewController.NavigationDirection
- if pageDirection == .rightToLeft {
- direction = safePageNum > currentPage ? .reverse : .forward
- } else {
- direction = safePageNum > currentPage ? .forward : .reverse
- }
- predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
- attachPageViewControllerIfNeeded()
- if isDualPage {
- let pair = dualPagePair(for: safePageNum)
- let leftContent = pageViewForDisplay(pageNum: pair.left)
- let leftVC = RDEpubReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
- isTransitioning = animated
- if let rightPage = pair.right {
- let rightContent = pageViewForDisplay(pageNum: rightPage)
- let rightVC = RDEpubReaderPageChildViewController(contentView: rightContent, pageNum: rightPage)
- pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
- guard let self else { return }
- self.finishPageCurlTransition()
- }
- } else {
-
- let blankNum = isFullScreenPage(pair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum
- let emptyVC = RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: blankNum)
- pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
- guard let self else { return }
- self.finishPageCurlTransition()
- }
- }
- currentPage = pair.left
- primePageCache(around: pair.left, preferredForward: predictedPageDirection)
- } else {
- let contentView = pageViewForDisplay(pageNum: safePageNum)
- let vc = RDEpubReaderPageChildViewController(contentView: contentView, pageNum: safePageNum)
- isTransitioning = animated
- pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
- guard let self else { return }
- self.finishPageCurlTransition()
- }
- currentPage = safePageNum
- primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
- }
- default:
- collectionView.reloadData()
- collectionView.layoutIfNeeded()
- predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
- collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
- currentPage = safePageNum
- primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
- }
- }
-
- public func reloadData() {
- invalidatePageCaches()
- switchReaderDisplayType(currentDisplayType)
- topToolView = resolvedTopChromeView()
- bottomToolView = resolvedBottomChromeView()
- }
-
- public func reloadPageCountOnly() {
- if currentDisplayType == .pageCurl {
- let totalPages = numberOfPages()
- if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
- transitionToPage(pageNum: totalPages - 1, animated: false)
- }
- } else {
- collectionView.reloadData()
- }
- topToolView = resolvedTopChromeView()
- bottomToolView = resolvedBottomChromeView()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
-}
-
-extension RDEpubReaderView: RDEpubReaderPageNavigating {
- public func reloadPages() {
- reloadData()
- }
-
- public func transition(to page: Int, animated: Bool) {
- transitionToPage(pageNum: page, animated: animated)
- }
-}
-
-extension RDEpubReaderView: UIGestureRecognizerDelegate {
- public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
- guard gestureRecognizer === tapGestureRecognizer else { return true }
-
- if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
- RDEpubReaderTapDebug.log(
- "ReaderView.gestureShouldReceive",
- "return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
- )
- return false
- }
-
- let point = touch.location(in: self)
- if containsTextContentView(in: touch.view) {
- RDEpubReaderTapDebug.log(
- "ReaderView.gestureShouldReceive",
- "return false because touch.view is inside text content view point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
- )
- return false
- }
- if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView")
- return false
- }
- if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView")
- return false
- }
- if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
- RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView")
- return false
- }
- RDEpubReaderTapDebug.log(
- "ReaderView.gestureShouldReceive",
- "return true point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
- )
- return true
- }
-
- public func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- gestureRecognizer === tapGestureRecognizer
- }
-}
-
-private var cellViewKey: Int8 = 0
-
-extension UIView {
-
- var ss_superViewController: UIViewController? {
- var next = self.next
- while next != nil {
- if next is UIViewController {
- return next as? UIViewController
- } else {
- next = next!.next
- }
- }
- return nil
- }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift
deleted file mode 100644
index de313e5..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-
-import UIKit
-
-@available(*, deprecated, message: "Use RDEpubReaderPageProvider instead.")
-@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
-
- func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
-
- func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
-
- func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
-
- @objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
-
- @objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
-}
-
-@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
-
- func numberOfPages(in readerView: RDEpubReaderView) -> Int
-
- func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
-
- @objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
-
- /// 指定页面是否允许进入阅读器预加载。默认允许。
- /// 包含不可复制宿主 View 的页面应返回 false,避免预加载抢占可见页面内容。
- @objc optional func readerView(_ readerView: RDEpubReaderView, shouldPreloadPageAt index: Int) -> Bool
-
- @objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
-
- @objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
-}
-
-@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
-
- func pageNum(readerView: RDEpubReaderView, pageNum: Int)
-
- @objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
-}
-
-public protocol RDEpubReaderPageNavigating: AnyObject {
-
- var currentPage: Int { get }
-
- func reloadPages()
-
- func transition(to page: Int, animated: Bool)
-}
-
-extension RDEpubReaderView {
-
- public enum DisplayType {
-
- case pageCurl
-
- case horizontalScroll
-
- case verticalScroll
- }
-
- public enum PageDirection {
-
- case leftToRight
-
- case rightToLeft
- }
-}
-
-final class RDEpubReaderLegacyDataSourceAdapter: NSObject, RDEpubReaderPageProvider {
-
- weak var dataSource: RDEpubReaderDataSource?
-
- func numberOfPages(in readerView: RDEpubReaderView) -> Int {
- dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0
- }
-
- func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
- dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView()
- }
-
- func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
- dataSource?.pageIdentifier(readerView: readerView, pageNum: index)
- }
-
- func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
- dataSource?.topToolView?(readerView: readerView)
- }
-
- func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? {
- dataSource?.bottomToolView?(readerView: readerView)
- }
-}