fix epub reader playback and trial navigation
This commit is contained in:
parent
d7fcda345d
commit
063a493b18
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
4
ReadViewDemo/Pods/Manifest.lock
generated
4
ReadViewDemo/Pods/Manifest.lock
generated
@ -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
|
||||
|
||||
36
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
36
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -56,4 +56,4 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
insets.right = horizontalInsets
|
||||
return insets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -55,7 +55,6 @@
|
||||
<script>
|
||||
(function() {
|
||||
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
|
||||
var safeAreaInsets = { top: {{INSET_TOP}}, right: {{INSET_RIGHT}}, bottom: {{INSET_BOTTOM}}, left: {{INSET_LEFT}} };
|
||||
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
|
||||
var fit = '{{FIT_MODE}}';
|
||||
var pendingFrames = 0;
|
||||
@ -206,10 +205,9 @@
|
||||
}
|
||||
|
||||
if (widthFit && scaledHeight > localViewport.height) {
|
||||
offsetY = safeAreaInsets.top;
|
||||
offsetY = 0;
|
||||
} else {
|
||||
offsetY = (localViewport.height - scaledHeight) / 2;
|
||||
offsetY += (safeAreaInsets.top - safeAreaInsets.bottom) / 2;
|
||||
}
|
||||
|
||||
iframe.style.left = offsetX + 'px';
|
||||
|
||||
@ -49,14 +49,24 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = item.title
|
||||
cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
|
||||
if isCurrentItem(item) {
|
||||
cell.textLabel?.textColor = .systemBlue
|
||||
} else if item.isReadable {
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
} else {
|
||||
cell.textLabel?.textColor = .systemGray
|
||||
}
|
||||
cell.selectionStyle = item.isReadable ? .default : .none
|
||||
cell.isUserInteractionEnabled = item.isReadable
|
||||
cell.indentationLevel = item.depth
|
||||
cell.indentationWidth = 18
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
onSelectItem?(items[indexPath.row])
|
||||
let item = items[indexPath.row]
|
||||
guard item.isReadable else { return }
|
||||
onSelectItem?(item)
|
||||
}
|
||||
|
||||
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
|
||||
|
||||
@ -15,6 +15,18 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
||||
pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
public func readerView(
|
||||
_ readerView: RDEpubReaderView,
|
||||
shouldPreloadPageAt index: Int
|
||||
) -> Bool {
|
||||
// 试读墙由宿主提供唯一 UIView,不允许预加载创建另一容器并抢走它。
|
||||
guard index != trialWallPageIndex else { return false }
|
||||
// 交互型固定版式页会在文档加载阶段触发页内音频/时间轴。
|
||||
// 预加载会把相邻章节一并载入,导致当前章打开时多段音频同时播放。
|
||||
// 这些页面只在真正展示时再创建,确保仅当前查看章节发声。
|
||||
return request(for: index)?.isFixedLayout != true
|
||||
}
|
||||
|
||||
public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||
topToolView(readerView: readerView)
|
||||
}
|
||||
@ -66,6 +78,7 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum),
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
@ -89,7 +102,8 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration
|
||||
configuration: configuration,
|
||||
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum)
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@ -111,6 +125,7 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: textBook.pages.count,
|
||||
configuration: configuration,
|
||||
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum),
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
|
||||
@ -48,12 +48,7 @@ extension RDEPUBReaderController {
|
||||
forPageNumber: pageIndex + 1,
|
||||
spineIndex: page.spineIndex
|
||||
)
|
||||
var preferences = currentPreferences()
|
||||
if publication.layout == .fixed, readerView.pagesPerScreen == 2 {
|
||||
// 横屏双页:左右页各让出 5pt,拼出 10pt 的书脊间距
|
||||
preferences.fixedContentInset.left += 5
|
||||
preferences.fixedContentInset.right += 5
|
||||
}
|
||||
let preferences = readerContext.currentPreferences(pageIndex: pageIndex)
|
||||
return preferences.renderRequest(
|
||||
for: page,
|
||||
publication: publication,
|
||||
|
||||
@ -50,12 +50,14 @@ extension RDEPUBReaderController {
|
||||
items.flatMap { item in
|
||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
|
||||
let isReadable = isReadableTableOfContentsLocation(location)
|
||||
|
||||
let current = RDEPUBReaderTableOfContentsItem(
|
||||
title: item.title,
|
||||
href: item.href,
|
||||
depth: depth,
|
||||
pageNumber: pageNumber
|
||||
pageNumber: pageNumber,
|
||||
isReadable: isReadable
|
||||
)
|
||||
return [current] + flattenedTableOfContentsItems(
|
||||
from: item.children,
|
||||
|
||||
@ -56,6 +56,17 @@ extension RDEPUBReaderController {
|
||||
return readableContentPageCount + 1
|
||||
}
|
||||
|
||||
func isReadableTableOfContentsLocation(_ location: RDEPUBLocation) -> Bool {
|
||||
guard let policy = configuration.trialPolicy,
|
||||
let readableChapterCount = policy.readableChapterCount else {
|
||||
return true
|
||||
}
|
||||
guard let spineIndex = readerContext.normalizedSpineIndex(for: location) else {
|
||||
return true
|
||||
}
|
||||
return spineIndex < readableChapterCount
|
||||
}
|
||||
|
||||
/// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图)
|
||||
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
|
||||
let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
|
||||
@ -65,12 +76,19 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
|
||||
/// 试读墙页容器:让宿主墙视图铺满整页
|
||||
final class RDEPUBTrialWallContainerView: UIView {
|
||||
final class RDEPUBTrialWallContainerView: UIView, RDEpubReaderCachePolicyProviding {
|
||||
|
||||
/// 宿主只提供一份试读墙 View。预加载会同时构建多个页容器,若缓存该容器会
|
||||
/// 把同一 View 从实际可见页移走,最终表现为横竖翻页的试读墙白屏。
|
||||
var shouldAvoidReaderPageCaching: Bool { true }
|
||||
|
||||
private weak var wallView: UIView?
|
||||
|
||||
func setWallView(_ view: UIView?) {
|
||||
guard wallView !== view else { return }
|
||||
// 即使 View 对象相同,只要它已被其他复用容器接管,也必须重新挂回当前页。
|
||||
if wallView === view, view?.superview === self {
|
||||
return
|
||||
}
|
||||
wallView?.removeFromSuperview()
|
||||
guard let view else { return }
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
@ -149,6 +149,10 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
|
||||
var cachedTrialWallView: UIView?
|
||||
|
||||
/// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本
|
||||
/// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。
|
||||
private var navigationBarHiddenBeforeReader: Bool?
|
||||
|
||||
private(set) var isSearchBarVisible = false
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
@ -275,6 +279,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
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
|
||||
@ -282,7 +289,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
if let navigationBarHiddenBeforeReader {
|
||||
navigationController?.setNavigationBarHidden(
|
||||
navigationBarHiddenBeforeReader,
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
|
||||
@ -10,10 +10,19 @@ public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
public var isReadable: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
href: String,
|
||||
depth: Int,
|
||||
pageNumber: Int? = nil,
|
||||
isReadable: Bool = true
|
||||
) {
|
||||
self.title = title
|
||||
self.href = href
|
||||
self.depth = depth
|
||||
self.pageNumber = pageNumber
|
||||
self.isReadable = isReadable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
|
||||
}
|
||||
|
||||
final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing {
|
||||
final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing, RDEpubReaderPageVisibilityHandling {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
/// The current href of the loaded chapter (exposed for image resolution).
|
||||
@ -90,6 +90,10 @@ final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing {
|
||||
self?.decorationOverlayView.applyDecorations(decorations)
|
||||
}
|
||||
}
|
||||
|
||||
func readerPageVisibilityDidChange(isVisible: Bool) {
|
||||
epubWebView.setReaderPageVisible(isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
||||
|
||||
@ -162,9 +162,12 @@ final class RDEPUBReaderContext {
|
||||
environment.currentLayoutContext(configuration: configuration)
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
||||
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
|
||||
environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
|
||||
}
|
||||
|
||||
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {
|
||||
currentPreferences(pageIndex: pageIndex).reflowableContentInsets
|
||||
}
|
||||
|
||||
/// Captures all layout parameters needed for background chapter loading.
|
||||
|
||||
@ -10,14 +10,59 @@ enum RDEPUBTextPageLayoutMetrics {
|
||||
|
||||
static func contentInsets(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
safeAreaInsets: UIEdgeInsets
|
||||
safeAreaInsets: UIEdgeInsets,
|
||||
isLandscapeDualPage: Bool = false,
|
||||
isLeadingPage: Bool = true
|
||||
) -> UIEdgeInsets {
|
||||
let configInsets = configuration.reflowableContentInsets
|
||||
guard isLandscapeDualPage else {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
let outerHorizontalInset = max(
|
||||
max(configInsets.left, configInsets.right),
|
||||
max(safeAreaInsets.left, safeAreaInsets.right)
|
||||
)
|
||||
let spineInset = RDEpubReaderView.landscapeDualPageGap / 2
|
||||
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)
|
||||
top: max(safeAreaInsets.top, min(configInsets.top, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||
left: isLeadingPage ? outerHorizontalInset : spineInset,
|
||||
bottom: max(safeAreaInsets.bottom, min(configInsets.bottom, RDEPUBSafeArea.landscapeVerticalTextMargin)) + pageNumberReservedHeight,
|
||||
right: isLeadingPage ? spineInset : outerHorizontalInset
|
||||
)
|
||||
}
|
||||
|
||||
static func fixedContentInsets(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
safeAreaInsets: UIEdgeInsets,
|
||||
isLandscapeDualPage: Bool,
|
||||
isLeadingPage: Bool
|
||||
) -> UIEdgeInsets {
|
||||
let configInsets = configuration.fixedContentInset
|
||||
guard isLandscapeDualPage else {
|
||||
return UIEdgeInsets(
|
||||
top: max(configInsets.top, safeAreaInsets.top),
|
||||
left: max(configInsets.left, safeAreaInsets.left),
|
||||
bottom: max(configInsets.bottom, safeAreaInsets.bottom),
|
||||
right: max(configInsets.right, safeAreaInsets.right)
|
||||
)
|
||||
}
|
||||
|
||||
let outerHorizontalInset = max(
|
||||
max(configInsets.left, configInsets.right),
|
||||
max(safeAreaInsets.left, safeAreaInsets.right)
|
||||
)
|
||||
let spineInset = RDEpubReaderView.landscapeDualPageGap / 2
|
||||
return UIEdgeInsets(
|
||||
top: max(safeAreaInsets.top, min(configInsets.top, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||
left: isLeadingPage ? outerHorizontalInset : spineInset,
|
||||
bottom: max(safeAreaInsets.bottom, min(configInsets.bottom, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||
right: isLeadingPage ? spineInset : outerHorizontalInset
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -74,7 +119,8 @@ final class RDEPUBReaderEnvironment {
|
||||
frameHeight: max(pageSize.height, 1),
|
||||
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
safeAreaInsets: safeAreaInsets,
|
||||
isLandscapeDualPage: readerView?.pagesPerScreen == 2
|
||||
),
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
@ -87,6 +133,29 @@ final class RDEPUBReaderEnvironment {
|
||||
)
|
||||
}
|
||||
|
||||
func currentPreferences(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
pageIndex: Int? = nil
|
||||
) -> RDEPUBPreferences {
|
||||
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||
let isLandscapeDualPage = readerView?.pagesPerScreen == 2
|
||||
let isLeadingPage = pageIndex.map { readerView?.isLeadingPage($0) ?? true } ?? true
|
||||
var preferences = configuration.makePreferences(safeAreaInsets: safeAreaInsets)
|
||||
preferences.reflowableContentInsets = RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets,
|
||||
isLandscapeDualPage: isLandscapeDualPage,
|
||||
isLeadingPage: isLeadingPage
|
||||
)
|
||||
preferences.fixedContentInset = RDEPUBTextPageLayoutMetrics.fixedContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets,
|
||||
isLandscapeDualPage: isLandscapeDualPage,
|
||||
isLeadingPage: isLeadingPage
|
||||
)
|
||||
return preferences
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { displayEnvironment.currentBrightness }
|
||||
set { displayEnvironment.currentBrightness = newValue }
|
||||
|
||||
@ -297,6 +297,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
private func notifySearchStateChanged() {
|
||||
guard let controller else { return }
|
||||
let state = controller.searchState
|
||||
// 搜索在后台队列执行,结果落地后需要同步刷新搜索面板计数;
|
||||
// 否则正文高亮已更新时,列表仍可能停留在“搜索中”或空状态。
|
||||
controller.updateSearchCount()
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
||||
}
|
||||
@ -411,4 +414,4 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,6 +398,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
contentInsets: UIEdgeInsets? = nil,
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
@ -414,7 +415,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = Self.safeContentInsets(
|
||||
self.contentInsets = contentInsets ?? Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||
)
|
||||
@ -491,7 +492,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
func configureLoading(
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
contentInsets: UIEdgeInsets? = nil
|
||||
) {
|
||||
currentPage = nil
|
||||
currentChapterCFIMap = nil
|
||||
@ -502,7 +504,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
currentHighlights = []
|
||||
currentSearchState = nil
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = Self.safeContentInsets(
|
||||
self.contentInsets = contentInsets ?? Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||
)
|
||||
|
||||
@ -12,6 +12,12 @@ protocol RDEpubReaderPageResourceReleasing {
|
||||
func releaseResources()
|
||||
}
|
||||
|
||||
/// 页面进入或离开当前阅读视口时的生命周期通知。
|
||||
/// 持有音视频等持续运行资源的页面应在离开时暂停,在重新进入时恢复。
|
||||
protocol RDEpubReaderPageVisibilityHandling: AnyObject {
|
||||
func readerPageVisibilityDidChange(isVisible: Bool)
|
||||
}
|
||||
|
||||
final class RDEpubReaderPreloadController {
|
||||
|
||||
var radius: Int = 2
|
||||
@ -121,6 +127,17 @@ final class RDEpubReaderPreloadController {
|
||||
return preloaded
|
||||
}
|
||||
|
||||
func updatePageVisibility(visiblePageNumbers: Set<Int>) {
|
||||
var notifiedViews = Set<ObjectIdentifier>()
|
||||
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
|
||||
for (pageNumber, view) in cachedViews {
|
||||
let identifier = ObjectIdentifier(view)
|
||||
guard notifiedViews.insert(identifier).inserted else { continue }
|
||||
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber))
|
||||
}
|
||||
}
|
||||
|
||||
func prime(
|
||||
around pageNum: Int,
|
||||
preferredForward: Bool? = nil,
|
||||
@ -164,7 +181,14 @@ final class RDEpubReaderPreloadController {
|
||||
"cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))"
|
||||
)
|
||||
} else {
|
||||
contentView = contentViewProvider(targetPage, nil) ?? UIView()
|
||||
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))"
|
||||
|
||||
@ -9,7 +9,10 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
|
||||
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)
|
||||
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(indexPath.row))
|
||||
cell.containerView = contentView
|
||||
|
||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
|
||||
@ -50,6 +50,10 @@ enum RDEpubReaderTapDebug {
|
||||
|
||||
public class RDEpubReaderView: UIView {
|
||||
|
||||
/// The readable-content gutter between two landscape pages. Each page
|
||||
/// contributes half of this value from its inner content inset.
|
||||
public static let landscapeDualPageGap: CGFloat = 10
|
||||
|
||||
enum TapEvent {
|
||||
|
||||
case none
|
||||
@ -93,6 +97,9 @@ public class RDEpubReaderView: UIView {
|
||||
|
||||
public var currentPage: Int = -1 {
|
||||
didSet {
|
||||
if currentPage != oldValue {
|
||||
updatePageContentVisibility()
|
||||
}
|
||||
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
|
||||
delegate.pageNum(readerView: self, pageNum: currentPage)
|
||||
}
|
||||
@ -233,6 +240,13 @@ public class RDEpubReaderView: UIView {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a page occupies the leading (left in LTR) side of a landscape
|
||||
/// spread. Cover pages remain full-screen and are treated as leading.
|
||||
public func isLeadingPage(_ pageNum: Int) -> Bool {
|
||||
guard pagesPerScreen > 1, !isFullScreenPage(pageNum) else { return true }
|
||||
return dualPagePair(for: pageNum).left == pageNum
|
||||
}
|
||||
|
||||
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
||||
let totalPages = numberOfPages()
|
||||
return spreadResolver.dualPagePair(
|
||||
@ -380,6 +394,13 @@ public class RDEpubReaderView: UIView {
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
|
||||
// Cached page views are configured with orientation-dependent content
|
||||
// insets and Web render requests. Reusing a portrait-preloaded page in
|
||||
// the rebuilt landscape page controller leaves the portrait safe-area
|
||||
// inset on all four edges (and keeps fixed-layout Web content under the
|
||||
// landscape sensor area). Drop them before requesting the new spread.
|
||||
invalidatePageCaches()
|
||||
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
|
||||
@ -503,11 +524,14 @@ public class RDEpubReaderView: UIView {
|
||||
}
|
||||
|
||||
func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||
preloadController.pageViewForDisplay(
|
||||
let pageView = preloadController.pageViewForDisplay(
|
||||
pageNum: pageNum,
|
||||
environment: preloadEnvironment,
|
||||
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
|
||||
contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:)
|
||||
)
|
||||
(pageView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(pageNum))
|
||||
return pageView
|
||||
}
|
||||
|
||||
func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
||||
@ -521,7 +545,50 @@ public class RDEpubReaderView: UIView {
|
||||
}
|
||||
|
||||
private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
|
||||
contentViewForPage(pageNum, reusableView: reusableView)
|
||||
let allowsPreloading = resolvedPageProvider?
|
||||
.readerView?(self, shouldPreloadPageAt: pageNum) ?? true
|
||||
guard allowsPreloading else { return nil }
|
||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||
}
|
||||
|
||||
private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
|
||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||
}
|
||||
|
||||
func isReaderPageVisible(_ pageNum: Int) -> Bool {
|
||||
guard currentPage >= 0 else { return pageNum == 0 }
|
||||
if landscapeDualPageEnabled && isLandscape {
|
||||
let pair = dualPagePair(for: currentPage)
|
||||
return pageNum == pair.left || pageNum == pair.right
|
||||
}
|
||||
return pageNum == currentPage
|
||||
}
|
||||
|
||||
private func updatePageContentVisibility() {
|
||||
var visiblePageNumbers = Set<Int>()
|
||||
if currentPage >= 0 {
|
||||
visiblePageNumbers.insert(currentPage)
|
||||
if landscapeDualPageEnabled && isLandscape,
|
||||
let rightPage = dualPagePair(for: currentPage).right {
|
||||
visiblePageNumbers.insert(rightPage)
|
||||
}
|
||||
}
|
||||
|
||||
preloadController.updatePageVisibility(visiblePageNumbers: visiblePageNumbers)
|
||||
|
||||
for viewController in pageViewController.viewControllers ?? [] {
|
||||
guard let pageController = viewController as? RDEpubReaderPageChildViewController,
|
||||
let contentView = pageController.contentView else { continue }
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageController.pageNum))
|
||||
}
|
||||
|
||||
for indexPath in collectionView.indexPathsForVisibleItems {
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
|
||||
let contentView = cell.containerView else { continue }
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(indexPath.item))
|
||||
}
|
||||
}
|
||||
|
||||
public override func didMoveToSuperview() {
|
||||
|
||||
@ -23,6 +23,10 @@ import UIKit
|
||||
|
||||
@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?
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFI: Codable, Equatable, Hashable {
|
||||
public var packagePath: RDEPUBCFIPath
|
||||
public var contentPath: RDEPUBCFIPath
|
||||
public var characterOffset: Int?
|
||||
public var sideBias: RDEPUBCFISideBias?
|
||||
public var textAssertion: RDEPUBCFITextAssertion?
|
||||
|
||||
/// Always computed from components — no stale cached value risk.
|
||||
public var rawValue: String {
|
||||
RDEPUBCFISerializer.serialize(self)
|
||||
}
|
||||
|
||||
/// Initialize from components. The `rawValue` parameter is ignored — rawValue is always
|
||||
/// computed from the components. To parse a CFI string, use `RDEPUBCFIParser.parse(_:)`.
|
||||
public init(
|
||||
rawValue: String = "",
|
||||
packagePath: RDEPUBCFIPath = RDEPUBCFIPath(),
|
||||
contentPath: RDEPUBCFIPath = RDEPUBCFIPath(),
|
||||
characterOffset: Int? = nil,
|
||||
sideBias: RDEPUBCFISideBias? = nil,
|
||||
textAssertion: RDEPUBCFITextAssertion? = nil
|
||||
) {
|
||||
self.packagePath = packagePath
|
||||
self.contentPath = contentPath
|
||||
self.characterOffset = characterOffset
|
||||
self.sideBias = sideBias
|
||||
self.textAssertion = textAssertion
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let rawValue = try container.decode(String.self)
|
||||
let parsed = try RDEPUBCFIParser.parse(rawValue)
|
||||
self.packagePath = parsed.packagePath
|
||||
self.contentPath = parsed.contentPath
|
||||
self.characterOffset = parsed.characterOffset
|
||||
self.sideBias = parsed.sideBias
|
||||
self.textAssertion = parsed.textAssertion
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBCFISideBias: String, Codable, Equatable, Hashable {
|
||||
case before = "b"
|
||||
case after = "a"
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFICompatibility {
|
||||
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? {
|
||||
try? RDEPUBCFIParser.parse(rawValue)
|
||||
}
|
||||
|
||||
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? {
|
||||
if let parsed = try? RDEPUBCFIParser.parseRange(rawValue) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
guard let rawValue,
|
||||
!rawValue.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let separators = ["..", "-"]
|
||||
let separator = separators.first(where: { rawValue.contains($0) })
|
||||
guard let separator else { return nil }
|
||||
let parts = rawValue.components(separatedBy: separator)
|
||||
guard parts.count == 2,
|
||||
let start = parseLossy(parts[0]),
|
||||
let end = parseLossy(parts[1]) else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBCFIRange(parent: nil, start: start, end: end)
|
||||
}
|
||||
}
|
||||
@ -1,575 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFIDOMPathBuilder {
|
||||
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] {
|
||||
// Pre-strip HTML comments and CDATA to avoid false tag matches inside them
|
||||
let cleaned = stripCommentsAndCDATA(html)
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let nsHTML = cleaned as NSString
|
||||
var stack: [RDEPUBCFIStep] = []
|
||||
var childCountsByDepth: [Int: Int] = [:]
|
||||
var paths: [String: RDEPUBCFIPath] = [:]
|
||||
|
||||
for match in regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)) {
|
||||
let rawTag = nsHTML.substring(with: match.range)
|
||||
guard match.numberOfRanges > 2 else { continue }
|
||||
let tagName = nsHTML.substring(with: match.range(at: 1)).lowercased()
|
||||
guard !isIgnorableTag(tagName) else { continue }
|
||||
|
||||
if rawTag.hasPrefix("</") {
|
||||
if tagName == "html" {
|
||||
continue
|
||||
}
|
||||
if !stack.isEmpty {
|
||||
stack.removeLast()
|
||||
childCountsByDepth[stack.count + 1] = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if tagName == "html" {
|
||||
childCountsByDepth[0] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
let attributes = nsHTML.substring(with: match.range(at: 2))
|
||||
let depth = stack.count
|
||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
||||
childCountsByDepth[depth] = nextChildIndex
|
||||
|
||||
let fragmentID = idAttribute(in: attributes)
|
||||
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
|
||||
let currentPath = RDEPUBCFIPath(steps: stack + [step])
|
||||
if let fragmentID, paths[fragmentID] == nil {
|
||||
paths[fragmentID] = currentPath
|
||||
}
|
||||
|
||||
if !rawTag.hasSuffix("/>"), !isVoidTag(tagName) {
|
||||
stack.append(step)
|
||||
childCountsByDepth[stack.count] = nil
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
static func idAttribute(in attributes: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?:^|\s)(?:id|xml:id)\s*=\s*(['"])(.*?)\1"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let nsAttributes = attributes as NSString
|
||||
guard let match = regex.firstMatch(
|
||||
in: attributes,
|
||||
range: NSRange(location: 0, length: nsAttributes.length)
|
||||
),
|
||||
match.numberOfRanges > 2 else {
|
||||
return nil
|
||||
}
|
||||
return nsAttributes.substring(with: match.range(at: 2)).rd_nilIfEmpty
|
||||
}
|
||||
|
||||
static func isIgnorableTag(_ tagName: String) -> Bool {
|
||||
tagName == "!doctype" || tagName.hasPrefix("?")
|
||||
}
|
||||
|
||||
/// Strip HTML comments `<!--...-->` and CDATA sections `<![CDATA[...]]>` from HTML
|
||||
/// to prevent the tag regex from matching false tags inside them.
|
||||
static func stripCommentsAndCDATA(_ html: String) -> String {
|
||||
var result = html
|
||||
// Strip comments: <!--...-->
|
||||
if let commentRegex = try? NSRegularExpression(pattern: #"<!--[\s\S]*?-->"#, options: []) {
|
||||
let nsResult = result as NSString
|
||||
let matches = commentRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
|
||||
for match in matches.reversed() {
|
||||
result = nsResult.replacingCharacters(in: match.range, with: "")
|
||||
}
|
||||
}
|
||||
// Strip CDATA: <![CDATA[...]]>
|
||||
if let cdataRegex = try? NSRegularExpression(pattern: #"<!\[CDATA\[[\s\S]*?\]\]>"#, options: []) {
|
||||
let nsResult = result as NSString
|
||||
let matches = cdataRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
|
||||
for match in matches.reversed() {
|
||||
result = nsResult.replacingCharacters(in: match.range, with: "")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func isVoidTag(_ tagName: String) -> Bool {
|
||||
[
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr"
|
||||
].contains(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBCFITextNodeMapBuilder {
|
||||
static func normalizedText(from source: String) -> String {
|
||||
RDEPUBNormalizedTextIndex.normalize(source)
|
||||
}
|
||||
|
||||
static func makeMap(
|
||||
href: String,
|
||||
rawHTML: String,
|
||||
chapterText: String,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> RDEPUBCFIMap {
|
||||
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
|
||||
let fragmentPaths = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
|
||||
let markers = makeMarkers(
|
||||
href: href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
let pathRanges = makePathRanges(from: markers)
|
||||
return RDEPUBCFIMap(
|
||||
href: href,
|
||||
markers: markers,
|
||||
pathRanges: pathRanges,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: rawHTML.rd_sha256Hex,
|
||||
normalizedTextChecksum: normalizedChapter.normalizedText.rd_sha256Hex,
|
||||
tokenIndex: makeTokenAnchors(
|
||||
normalizedChapter: normalizedChapter,
|
||||
markers: markers,
|
||||
pathRanges: pathRanges
|
||||
),
|
||||
fragmentPathMap: fragmentPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func makeMarkers(
|
||||
href: String,
|
||||
rawHTML: String,
|
||||
chapterText: String,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBCFIMarker] {
|
||||
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
|
||||
guard !normalizedChapter.normalizedText.isEmpty else {
|
||||
return fragmentMarkers(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
// Use comment/CDATA-stripped HTML for both path building and tag matching
|
||||
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
|
||||
|
||||
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
|
||||
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
|
||||
var searchLocation = 0
|
||||
var stack: [RDEPUBCFIStep] = []
|
||||
var childCountsByDepth: [Int: Int] = [:]
|
||||
var lastTagEnd = cleanedHTML.startIndex
|
||||
|
||||
for tagMatch in RDEPUBHTMLTagMatch.matches(in: cleanedHTML) {
|
||||
let textChunk = String(cleanedHTML[lastTagEnd..<tagMatch.range.lowerBound])
|
||||
if let marker = textMarker(
|
||||
for: textChunk,
|
||||
stack: stack,
|
||||
childCountsByDepth: &childCountsByDepth,
|
||||
normalizedChapter: normalizedChapter,
|
||||
searchLocation: &searchLocation
|
||||
) {
|
||||
markers.append(marker)
|
||||
}
|
||||
|
||||
processTag(
|
||||
tagMatch,
|
||||
stack: &stack,
|
||||
childCountsByDepth: &childCountsByDepth
|
||||
)
|
||||
lastTagEnd = tagMatch.range.upperBound
|
||||
}
|
||||
|
||||
if lastTagEnd < cleanedHTML.endIndex {
|
||||
let trailingText = String(cleanedHTML[lastTagEnd..<cleanedHTML.endIndex])
|
||||
if let marker = textMarker(
|
||||
for: trailingText,
|
||||
stack: stack,
|
||||
childCountsByDepth: &childCountsByDepth,
|
||||
normalizedChapter: normalizedChapter,
|
||||
searchLocation: &searchLocation
|
||||
) {
|
||||
markers.append(marker)
|
||||
}
|
||||
}
|
||||
|
||||
return markers
|
||||
.filter { $0.chapterOffset != nil }
|
||||
.sorted { lhs, rhs in
|
||||
let leftOffset = lhs.chapterOffset ?? Int.max
|
||||
let rightOffset = rhs.chapterOffset ?? Int.max
|
||||
if leftOffset != rightOffset {
|
||||
return leftOffset < rightOffset
|
||||
}
|
||||
return lhs.cfiPath.steps.count < rhs.cfiPath.steps.count
|
||||
}
|
||||
}
|
||||
|
||||
private static func fragmentMarkers(
|
||||
fragmentOffsets: [String: Int],
|
||||
paths: [String: RDEPUBCFIPath] = [:]
|
||||
) -> [RDEPUBCFIMarker] {
|
||||
fragmentOffsets
|
||||
.sorted { $0.value < $1.value }
|
||||
.map { fragmentID, offset in
|
||||
RDEPUBCFIMarker(
|
||||
cfiPath: paths[fragmentID] ?? RDEPUBCFIPath(),
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func textMarker(
|
||||
for rawText: String,
|
||||
stack: [RDEPUBCFIStep],
|
||||
childCountsByDepth: inout [Int: Int],
|
||||
normalizedChapter: RDEPUBNormalizedTextIndex,
|
||||
searchLocation: inout Int
|
||||
) -> RDEPUBCFIMarker? {
|
||||
let normalizedNodeText = RDEPUBNormalizedTextIndex.normalize(rawText)
|
||||
guard !normalizedNodeText.isEmpty else { return nil }
|
||||
|
||||
let depth = stack.count
|
||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
||||
childCountsByDepth[depth] = nextChildIndex
|
||||
let textStep = RDEPUBCFIStep(index: nextChildIndex * 2 - 1)
|
||||
let path = RDEPUBCFIPath(steps: stack + [textStep])
|
||||
|
||||
// Search using UTF-16 offsets to match normalizedToChapterOffsets indices
|
||||
let nsNormalized = normalizedChapter.normalizedText as NSString
|
||||
let searchRange = NSRange(location: searchLocation, length: nsNormalized.length - searchLocation)
|
||||
guard let foundRange = nsNormalized.range(of: normalizedNodeText, options: [], range: searchRange).toRange(),
|
||||
foundRange.lowerBound >= searchLocation else {
|
||||
return nil
|
||||
}
|
||||
|
||||
searchLocation = foundRange.upperBound
|
||||
let normalizedOffset = foundRange.lowerBound
|
||||
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: normalizedOffset) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBCFIMarker(
|
||||
cfiPath: path,
|
||||
chapterOffset: chapterOffset,
|
||||
textNodeLength: normalizedNodeText.utf16.count,
|
||||
textNodeChecksum: normalizedNodeText.fnv1a64,
|
||||
normalizedTextPreview: previewText(for: normalizedNodeText),
|
||||
domSiblingSignature: siblingSignature(stack: stack, childIndex: nextChildIndex)
|
||||
)
|
||||
}
|
||||
|
||||
private static func processTag(
|
||||
_ match: RDEPUBHTMLTagMatch,
|
||||
stack: inout [RDEPUBCFIStep],
|
||||
childCountsByDepth: inout [Int: Int]
|
||||
) {
|
||||
let tagName = match.name.lowercased()
|
||||
guard !RDEPUBCFIDOMPathBuilder.isIgnorableTag(tagName) else { return }
|
||||
|
||||
if match.isClosing {
|
||||
if tagName == "html" {
|
||||
return
|
||||
}
|
||||
if !stack.isEmpty {
|
||||
stack.removeLast()
|
||||
childCountsByDepth[stack.count + 1] = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if tagName == "html" {
|
||||
childCountsByDepth[0] = nil
|
||||
return
|
||||
}
|
||||
|
||||
let depth = stack.count
|
||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
||||
childCountsByDepth[depth] = nextChildIndex
|
||||
|
||||
let fragmentID = RDEPUBCFIDOMPathBuilder.idAttribute(in: match.attributes)
|
||||
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
|
||||
if !match.isSelfClosing && !RDEPUBCFIDOMPathBuilder.isVoidTag(tagName) {
|
||||
stack.append(step)
|
||||
childCountsByDepth[stack.count] = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func makePathRanges(from markers: [RDEPUBCFIMarker]) -> [RDEPUBCFIPathRange] {
|
||||
markers.compactMap { marker in
|
||||
guard let startOffset = marker.chapterOffset,
|
||||
let textNodeLength = marker.textNodeLength,
|
||||
textNodeLength > 0 else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBCFIPathRange(
|
||||
cfiPath: marker.cfiPath,
|
||||
startOffset: startOffset,
|
||||
endOffset: startOffset + max(textNodeLength - 1, 0),
|
||||
textNodeLength: textNodeLength
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeTokenAnchors(
|
||||
normalizedChapter: RDEPUBNormalizedTextIndex,
|
||||
markers: [RDEPUBCFIMarker],
|
||||
pathRanges: [RDEPUBCFIPathRange]
|
||||
) -> [RDEPUBCFITokenAnchor] {
|
||||
guard !normalizedChapter.normalizedText.isEmpty else { return [] }
|
||||
|
||||
let sortedRanges = pathRanges.sorted { $0.startOffset < $1.startOffset }
|
||||
var occurrenceByToken: [String: Int] = [:]
|
||||
var anchors: [RDEPUBCFITokenAnchor] = []
|
||||
let samples = normalizedChapter.tokenSamples(maxSamples: 64, stride: 96, window: 12)
|
||||
|
||||
for sample in samples {
|
||||
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: sample.offset),
|
||||
let path = path(for: chapterOffset, ranges: sortedRanges, markers: markers) else {
|
||||
continue
|
||||
}
|
||||
let occurrence = (occurrenceByToken[sample.token] ?? 0) + 1
|
||||
occurrenceByToken[sample.token] = occurrence
|
||||
anchors.append(
|
||||
RDEPUBCFITokenAnchor(
|
||||
token: sample.token,
|
||||
occurrence: occurrence,
|
||||
chapterOffset: chapterOffset,
|
||||
cfiPath: path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return anchors
|
||||
}
|
||||
|
||||
private static func path(
|
||||
for chapterOffset: Int,
|
||||
ranges: [RDEPUBCFIPathRange],
|
||||
markers: [RDEPUBCFIMarker]
|
||||
) -> RDEPUBCFIPath? {
|
||||
if let range = ranges.first(where: { chapterOffset >= $0.startOffset && chapterOffset <= $0.endOffset }) {
|
||||
return range.cfiPath
|
||||
}
|
||||
return markers
|
||||
.filter { $0.chapterOffset != nil }
|
||||
.min(by: {
|
||||
abs(($0.chapterOffset ?? 0) - chapterOffset) < abs(($1.chapterOffset ?? 0) - chapterOffset)
|
||||
})?
|
||||
.cfiPath
|
||||
}
|
||||
|
||||
private static func previewText(for normalizedText: String, limit: Int = 24) -> String {
|
||||
String(normalizedText.prefix(limit))
|
||||
}
|
||||
|
||||
private static func siblingSignature(stack: [RDEPUBCFIStep], childIndex: Int) -> String {
|
||||
let parent = stack.map { String($0.index) }.joined(separator: "/")
|
||||
return "\(parent)#\(childIndex)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct RDEPUBHTMLTagMatch {
|
||||
let range: Range<String.Index>
|
||||
let name: String
|
||||
let attributes: String
|
||||
let isClosing: Bool
|
||||
let isSelfClosing: Bool
|
||||
|
||||
static func matches(in html: String) -> [RDEPUBHTMLTagMatch] {
|
||||
let cleaned = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(html)
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let nsHTML = cleaned as NSString
|
||||
return regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
|
||||
guard match.numberOfRanges > 2,
|
||||
let range = Range(match.range, in: cleaned),
|
||||
let nameRange = Range(match.range(at: 1), in: cleaned),
|
||||
let attributesRange = Range(match.range(at: 2), in: cleaned) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawTag = String(cleaned[range])
|
||||
return RDEPUBHTMLTagMatch(
|
||||
range: range,
|
||||
name: String(cleaned[nameRange]),
|
||||
attributes: String(cleaned[attributesRange]),
|
||||
isClosing: rawTag.hasPrefix("</"),
|
||||
isSelfClosing: rawTag.hasSuffix("/>")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RDEPUBNormalizedTextIndex {
|
||||
let normalizedText: String
|
||||
private let normalizedToChapterOffsets: [Int]
|
||||
|
||||
init(source: String) {
|
||||
var text = ""
|
||||
var offsets: [Int] = []
|
||||
let nsSource = source as NSString
|
||||
var lastWasWhitespace = false
|
||||
|
||||
for index in 0..<nsSource.length {
|
||||
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
|
||||
let normalizedScalar = Self.normalizeCharacter(scalar)
|
||||
if normalizedScalar == " " {
|
||||
if lastWasWhitespace {
|
||||
continue
|
||||
}
|
||||
lastWasWhitespace = true
|
||||
} else {
|
||||
lastWasWhitespace = false
|
||||
}
|
||||
text.append(normalizedScalar)
|
||||
offsets.append(index)
|
||||
}
|
||||
|
||||
self.normalizedText = text
|
||||
self.normalizedToChapterOffsets = offsets
|
||||
}
|
||||
|
||||
func chapterOffset(forNormalizedOffset offset: Int) -> Int? {
|
||||
guard normalizedToChapterOffsets.indices.contains(offset) else { return nil }
|
||||
return normalizedToChapterOffsets[offset]
|
||||
}
|
||||
|
||||
func tokenSamples(maxSamples: Int, stride: Int, window: Int) -> [(token: String, offset: Int)] {
|
||||
guard !normalizedText.isEmpty, stride > 0, window > 0 else { return [] }
|
||||
// Use NSString-based (UTF-16) iteration to match normalizedToChapterOffsets indices
|
||||
let nsText = normalizedText as NSString
|
||||
let length = nsText.length
|
||||
var samples: [(token: String, offset: Int)] = []
|
||||
var index = 0
|
||||
|
||||
while index < length, samples.count < maxSamples {
|
||||
let end = min(index + window, length)
|
||||
let token = nsText.substring(with: NSRange(location: index, length: end - index))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.utf16.count >= min(window, 4) {
|
||||
samples.append((token, index))
|
||||
}
|
||||
index += stride
|
||||
}
|
||||
|
||||
if samples.isEmpty {
|
||||
let token = nsText.substring(with: NSRange(location: 0, length: min(window, length)))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !token.isEmpty {
|
||||
samples.append((token, 0))
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
static func normalize(_ source: String) -> String {
|
||||
var result = ""
|
||||
var lastWasWhitespace = false
|
||||
let decodedSource = RDEPUBHTMLEntityDecoder.decodeEntities(in: source)
|
||||
let nsSource = decodedSource as NSString
|
||||
|
||||
for index in 0..<nsSource.length {
|
||||
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
|
||||
let normalizedScalar = normalizeCharacter(scalar)
|
||||
if normalizedScalar == " " {
|
||||
if lastWasWhitespace {
|
||||
continue
|
||||
}
|
||||
lastWasWhitespace = true
|
||||
} else {
|
||||
lastWasWhitespace = false
|
||||
}
|
||||
result.append(normalizedScalar)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func normalizeCharacter(_ scalar: String) -> Character {
|
||||
if scalar == "\u{00A0}" {
|
||||
return " "
|
||||
}
|
||||
if scalar.unicodeScalars.allSatisfy(\.properties.isWhitespace) {
|
||||
return " "
|
||||
}
|
||||
return scalar.first ?? " "
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var fnv1a64: UInt64 {
|
||||
let offsetBasis: UInt64 = 14695981039346656037
|
||||
let prime: UInt64 = 1099511628211
|
||||
return utf8.reduce(offsetBasis) { partialResult, byte in
|
||||
(partialResult ^ UInt64(byte)) &* prime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RDEPUBHTMLEntityDecoder {
|
||||
private static let namedEntities: [String: String] = [
|
||||
" ": " ",
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'"
|
||||
]
|
||||
|
||||
static func decode(_ value: String) -> String {
|
||||
if let mapped = namedEntities[value] {
|
||||
return mapped
|
||||
}
|
||||
guard value.hasPrefix("&"), value.hasSuffix(";") else {
|
||||
return value
|
||||
}
|
||||
let body = String(value.dropFirst().dropLast())
|
||||
if body.hasPrefix("#x") || body.hasPrefix("#X"),
|
||||
let scalar = UInt32(body.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) {
|
||||
return String(Character(scalar))
|
||||
}
|
||||
if body.hasPrefix("#"),
|
||||
let scalar = UInt32(body.dropFirst(), radix: 10).flatMap(UnicodeScalar.init) {
|
||||
return String(Character(scalar))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func decodeEntities(in value: String) -> String {
|
||||
guard value.contains("&"),
|
||||
let regex = try? NSRegularExpression(pattern: #"&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);"#) else {
|
||||
return value
|
||||
}
|
||||
|
||||
let nsValue = value as NSString
|
||||
var decoded = value
|
||||
for match in regex.matches(in: value, range: NSRange(location: 0, length: nsValue.length)).reversed() {
|
||||
let entity = nsValue.substring(with: match.range)
|
||||
let replacement = decode(entity)
|
||||
if let range = Range(match.range, in: decoded) {
|
||||
decoded.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFIError: Error, Equatable {
|
||||
case empty
|
||||
case invalidWrapper(String)
|
||||
case invalidPath(String)
|
||||
case unsupportedRange(String)
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFIGenerator {
|
||||
|
||||
public static func makeOffsetCFI(
|
||||
href: String,
|
||||
fileIndex: Int,
|
||||
chapterOffset: Int,
|
||||
fragmentID: String? = nil,
|
||||
sideBias: RDEPUBCFISideBias? = nil,
|
||||
textAssertion: RDEPUBCFITextAssertion? = nil,
|
||||
contentPath: RDEPUBCFIPath? = nil
|
||||
) -> RDEPUBCFI {
|
||||
let packagePath = RDEPUBCFIPath(steps: [
|
||||
RDEPUBCFIStep(index: 6),
|
||||
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
|
||||
])
|
||||
let resolvedContentPath = contentPath ?? RDEPUBCFIPath(steps: [
|
||||
RDEPUBCFIStep(index: 4),
|
||||
RDEPUBCFIStep(index: 2, idAssertion: fragmentID)
|
||||
])
|
||||
return RDEPUBCFI(
|
||||
packagePath: packagePath,
|
||||
contentPath: resolvedContentPath,
|
||||
characterOffset: max(chapterOffset, 0),
|
||||
sideBias: sideBias,
|
||||
textAssertion: textAssertion
|
||||
)
|
||||
}
|
||||
|
||||
public static func makeCFI(
|
||||
href: String,
|
||||
fileIndex: Int,
|
||||
contentPath: RDEPUBCFIPath,
|
||||
characterOffset: Int,
|
||||
sideBias: RDEPUBCFISideBias? = nil,
|
||||
textAssertion: RDEPUBCFITextAssertion? = nil
|
||||
) -> RDEPUBCFI {
|
||||
let packagePath = RDEPUBCFIPath(steps: [
|
||||
RDEPUBCFIStep(index: 6),
|
||||
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
|
||||
])
|
||||
return RDEPUBCFI(
|
||||
packagePath: packagePath,
|
||||
contentPath: contentPath,
|
||||
characterOffset: max(characterOffset, 0),
|
||||
sideBias: sideBias,
|
||||
textAssertion: textAssertion
|
||||
)
|
||||
}
|
||||
|
||||
public static func makeOffsetRangeCFI(
|
||||
href: String,
|
||||
fileIndex: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int,
|
||||
fragmentID: String? = nil,
|
||||
startTextAssertion: RDEPUBCFITextAssertion? = nil,
|
||||
endTextAssertion: RDEPUBCFITextAssertion? = nil,
|
||||
contentPath: RDEPUBCFIPath? = nil
|
||||
) -> RDEPUBCFIRange {
|
||||
precondition(startOffset <= endOffset, "startOffset (\(startOffset)) must be <= endOffset (\(endOffset))")
|
||||
let start = makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: fileIndex,
|
||||
chapterOffset: startOffset,
|
||||
fragmentID: fragmentID,
|
||||
sideBias: .before,
|
||||
textAssertion: startTextAssertion,
|
||||
contentPath: contentPath
|
||||
)
|
||||
let end = makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: fileIndex,
|
||||
chapterOffset: endOffset,
|
||||
fragmentID: fragmentID,
|
||||
sideBias: .after,
|
||||
textAssertion: endTextAssertion,
|
||||
contentPath: contentPath
|
||||
)
|
||||
let parent = RDEPUBCFI(
|
||||
packagePath: start.packagePath.commonPrefix(with: end.packagePath),
|
||||
contentPath: start.contentPath.commonPrefix(with: end.contentPath)
|
||||
)
|
||||
return RDEPUBCFIRange(parent: parent, start: start, end: end)
|
||||
}
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||
public var href: String
|
||||
public var renderVersion: Int
|
||||
public var domVersion: Int
|
||||
public var markers: [RDEPUBCFIMarker]
|
||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
||||
public var pathRanges: [RDEPUBCFIPathRange]
|
||||
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
|
||||
|
||||
/// Lazy O(1) lookup index for markers by cfiPath
|
||||
private var markerByPath: [RDEPUBCFIPath: Int]?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
renderVersion: Int = 1,
|
||||
domVersion: Int = 1,
|
||||
markers: [RDEPUBCFIMarker] = [],
|
||||
textAssertions: [String: RDEPUBCFITextAssertion] = [:],
|
||||
pathRanges: [RDEPUBCFIPathRange] = [],
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata = .empty
|
||||
) {
|
||||
self.href = href
|
||||
self.renderVersion = renderVersion
|
||||
self.domVersion = domVersion
|
||||
self.markers = markers
|
||||
self.textAssertions = textAssertions
|
||||
self.pathRanges = pathRanges
|
||||
self.recoveryMetadata = recoveryMetadata
|
||||
}
|
||||
|
||||
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
|
||||
if let index = markerByPath?[path] {
|
||||
return markers[index]
|
||||
}
|
||||
return markers.first { $0.cfiPath == path }
|
||||
}
|
||||
|
||||
/// Build or rebuild the marker lookup index. Call after mutating `markers`.
|
||||
public mutating func rebuildMarkerIndex() {
|
||||
markerByPath = Dictionary(
|
||||
markers.enumerated().map { ($0.element.cfiPath, $0.offset) },
|
||||
uniquingKeysWith: { _, last in last }
|
||||
)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case href
|
||||
case renderVersion
|
||||
case domVersion
|
||||
case markers
|
||||
case textAssertions
|
||||
case pathRanges
|
||||
case recoveryMetadata
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
href = try container.decode(String.self, forKey: .href)
|
||||
renderVersion = try container.decodeIfPresent(Int.self, forKey: .renderVersion) ?? 1
|
||||
domVersion = try container.decodeIfPresent(Int.self, forKey: .domVersion) ?? 1
|
||||
markers = try container.decodeIfPresent([RDEPUBCFIMarker].self, forKey: .markers) ?? []
|
||||
textAssertions = try container.decodeIfPresent([String: RDEPUBCFITextAssertion].self, forKey: .textAssertions) ?? [:]
|
||||
pathRanges = try container.decodeIfPresent([RDEPUBCFIPathRange].self, forKey: .pathRanges) ?? []
|
||||
recoveryMetadata = try container.decodeIfPresent(RDEPUBCFIRecoveryMetadata.self, forKey: .recoveryMetadata) ?? .empty
|
||||
markerByPath = nil
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(href, forKey: .href)
|
||||
try container.encode(renderVersion, forKey: .renderVersion)
|
||||
try container.encode(domVersion, forKey: .domVersion)
|
||||
try container.encode(markers, forKey: .markers)
|
||||
try container.encode(textAssertions, forKey: .textAssertions)
|
||||
try container.encode(pathRanges, forKey: .pathRanges)
|
||||
try container.encode(recoveryMetadata, forKey: .recoveryMetadata)
|
||||
}
|
||||
|
||||
public static func == (lhs: RDEPUBCFIMap, rhs: RDEPUBCFIMap) -> Bool {
|
||||
lhs.href == rhs.href
|
||||
&& lhs.renderVersion == rhs.renderVersion
|
||||
&& lhs.domVersion == rhs.domVersion
|
||||
&& lhs.markers == rhs.markers
|
||||
&& lhs.textAssertions == rhs.textAssertions
|
||||
&& lhs.pathRanges == rhs.pathRanges
|
||||
&& lhs.recoveryMetadata == rhs.recoveryMetadata
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBCFIMarker: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var chapterOffset: Int?
|
||||
public var fragmentID: String?
|
||||
public var textNodeLength: Int?
|
||||
public var textNodeChecksum: UInt64?
|
||||
public var normalizedTextPreview: String?
|
||||
public var domSiblingSignature: String?
|
||||
|
||||
public init(
|
||||
cfiPath: RDEPUBCFIPath,
|
||||
chapterOffset: Int? = nil,
|
||||
fragmentID: String? = nil,
|
||||
textNodeLength: Int? = nil,
|
||||
textNodeChecksum: UInt64? = nil,
|
||||
normalizedTextPreview: String? = nil,
|
||||
domSiblingSignature: String? = nil
|
||||
) {
|
||||
self.cfiPath = cfiPath
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID
|
||||
self.textNodeLength = textNodeLength
|
||||
self.textNodeChecksum = textNodeChecksum
|
||||
self.normalizedTextPreview = normalizedTextPreview
|
||||
self.domSiblingSignature = domSiblingSignature
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBCFIPathRange: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var startOffset: Int
|
||||
public var endOffset: Int
|
||||
public var textNodeLength: Int
|
||||
|
||||
public init(
|
||||
cfiPath: RDEPUBCFIPath,
|
||||
startOffset: Int,
|
||||
endOffset: Int,
|
||||
textNodeLength: Int
|
||||
) {
|
||||
self.cfiPath = cfiPath
|
||||
self.startOffset = startOffset
|
||||
self.endOffset = endOffset
|
||||
self.textNodeLength = textNodeLength
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
|
||||
public var domFingerprint: String
|
||||
public var normalizedTextChecksum: String
|
||||
public var tokenIndex: [RDEPUBCFITokenAnchor]
|
||||
public var fragmentPathMap: [String: RDEPUBCFIPath]
|
||||
|
||||
public init(
|
||||
domFingerprint: String,
|
||||
normalizedTextChecksum: String,
|
||||
tokenIndex: [RDEPUBCFITokenAnchor] = [],
|
||||
fragmentPathMap: [String: RDEPUBCFIPath] = [:]
|
||||
) {
|
||||
self.domFingerprint = domFingerprint
|
||||
self.normalizedTextChecksum = normalizedTextChecksum
|
||||
self.tokenIndex = tokenIndex
|
||||
self.fragmentPathMap = fragmentPathMap
|
||||
}
|
||||
|
||||
public static let empty = RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: "",
|
||||
tokenIndex: [],
|
||||
fragmentPathMap: [:]
|
||||
)
|
||||
}
|
||||
|
||||
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||
public var token: String
|
||||
public var occurrence: Int
|
||||
public var chapterOffset: Int
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
|
||||
public init(
|
||||
token: String,
|
||||
occurrence: Int,
|
||||
chapterOffset: Int,
|
||||
cfiPath: RDEPUBCFIPath
|
||||
) {
|
||||
self.token = token
|
||||
self.occurrence = occurrence
|
||||
self.chapterOffset = chapterOffset
|
||||
self.cfiPath = cfiPath
|
||||
}
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFIParser {
|
||||
|
||||
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI {
|
||||
|
||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawValue.isEmpty else {
|
||||
throw RDEPUBCFIError.empty
|
||||
}
|
||||
|
||||
let body = try unwrap(rawValue)
|
||||
|
||||
let parts = body.split(separator: "!", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
|
||||
let packagePath = try parsePath(String(parts.first ?? ""))
|
||||
|
||||
let contentBody = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
let parsedContent = try parsePathWithOffset(contentBody)
|
||||
|
||||
return RDEPUBCFI(
|
||||
packagePath: packagePath,
|
||||
contentPath: parsedContent.path,
|
||||
characterOffset: parsedContent.characterOffset,
|
||||
sideBias: parsedContent.sideBias,
|
||||
textAssertion: parsedContent.textAssertion
|
||||
)
|
||||
}
|
||||
|
||||
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange {
|
||||
|
||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawValue.isEmpty else {
|
||||
throw RDEPUBCFIError.empty
|
||||
}
|
||||
|
||||
let body = try unwrap(rawValue)
|
||||
|
||||
let rangeParts = body.split(separator: ",", omittingEmptySubsequences: false)
|
||||
guard rangeParts.count == 3 else {
|
||||
throw RDEPUBCFIError.unsupportedRange(rawValue)
|
||||
}
|
||||
|
||||
let parentRaw = "epubcfi(\(rangeParts[0]))"
|
||||
|
||||
let startRaw = "epubcfi(\(rangeParts[0])\(rangeParts[1]))"
|
||||
|
||||
let endRaw = "epubcfi(\(rangeParts[0])\(rangeParts[2]))"
|
||||
return RDEPUBCFIRange(
|
||||
parent: try parse(parentRaw),
|
||||
start: try parse(startRaw),
|
||||
end: try parse(endRaw)
|
||||
)
|
||||
}
|
||||
|
||||
private static func unwrap(_ rawValue: String) throws -> String {
|
||||
guard rawValue.hasPrefix("epubcfi("), rawValue.hasSuffix(")") else {
|
||||
throw RDEPUBCFIError.invalidWrapper(rawValue)
|
||||
}
|
||||
return String(rawValue.dropFirst("epubcfi(".count).dropLast())
|
||||
}
|
||||
|
||||
private static func parsePathWithOffset(_ body: String) throws -> (
|
||||
path: RDEPUBCFIPath,
|
||||
characterOffset: Int?,
|
||||
sideBias: RDEPUBCFISideBias?,
|
||||
textAssertion: RDEPUBCFITextAssertion?
|
||||
) {
|
||||
|
||||
guard let offsetSeparator = firstIndexOutsideBrackets(of: ":", in: body) else {
|
||||
|
||||
return (try parsePath(body), nil, nil, nil)
|
||||
}
|
||||
|
||||
let pathPart = String(body[..<offsetSeparator])
|
||||
let offsetAndQualifiers = String(body[body.index(after: offsetSeparator)...])
|
||||
let path = try parsePath(pathPart)
|
||||
|
||||
let offsetDigits = offsetAndQualifiers.prefix { $0.isNumber }
|
||||
let offset = Int(offsetDigits)
|
||||
|
||||
let qualifierStart = offsetAndQualifiers.index(
|
||||
offsetAndQualifiers.startIndex,
|
||||
offsetBy: offsetDigits.count
|
||||
)
|
||||
let qualifiers = String(offsetAndQualifiers[qualifierStart...])
|
||||
return (
|
||||
path,
|
||||
offset,
|
||||
parseSideBias(from: qualifiers),
|
||||
parseTextAssertion(from: qualifiers)
|
||||
)
|
||||
}
|
||||
|
||||
private static func parsePath(_ path: String) throws -> RDEPUBCFIPath {
|
||||
|
||||
guard path.isEmpty || path.hasPrefix("/") else {
|
||||
throw RDEPUBCFIError.invalidPath(path)
|
||||
}
|
||||
|
||||
let steps = path
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.compactMap { parseStep(String($0)) }
|
||||
return RDEPUBCFIPath(steps: steps)
|
||||
}
|
||||
|
||||
private static func parseStep(_ rawStep: String) -> RDEPUBCFIStep? {
|
||||
|
||||
let indexPart = rawStep.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? rawStep
|
||||
guard let index = Int(indexPart) else { return nil }
|
||||
|
||||
let idAssertion: String?
|
||||
if let open = rawStep.firstIndex(of: "["),
|
||||
let close = rawStep.lastIndex(of: "]"),
|
||||
open < close {
|
||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_nilIfEmpty
|
||||
} else {
|
||||
idAssertion = nil
|
||||
}
|
||||
return RDEPUBCFIStep(index: index, idAssertion: idAssertion)
|
||||
}
|
||||
|
||||
private static func parseSideBias(from body: String) -> RDEPUBCFISideBias? {
|
||||
|
||||
guard let markerRange = body.range(of: ";s=") else { return nil }
|
||||
let value = body[markerRange.upperBound...].prefix(1)
|
||||
return RDEPUBCFISideBias(rawValue: String(value))
|
||||
}
|
||||
|
||||
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
|
||||
|
||||
// Scan for the opening bracket, respecting backslash escapes
|
||||
guard let open = firstUnescapedIndex(of: "[", in: body) else { return nil }
|
||||
|
||||
// Find the matching closing bracket, respecting backslash escapes and nesting
|
||||
var depth = 1
|
||||
var current = body.index(after: open)
|
||||
while current < body.endIndex && depth > 0 {
|
||||
if body[current] == "\\" {
|
||||
// Skip escaped character
|
||||
current = body.index(after: current)
|
||||
if current < body.endIndex {
|
||||
current = body.index(after: current)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if body[current] == "[" {
|
||||
depth += 1
|
||||
} else if body[current] == "]" {
|
||||
depth -= 1
|
||||
}
|
||||
if depth > 0 {
|
||||
current = body.index(after: current)
|
||||
}
|
||||
}
|
||||
|
||||
guard depth == 0, current <= body.endIndex else { return nil }
|
||||
let close = current
|
||||
|
||||
// Extract content, processing escape sequences left-to-right
|
||||
let raw = String(body[body.index(after: open)..<close])
|
||||
let unescaped = unescapeCFIText(raw)
|
||||
|
||||
// Split by comma, but only commas that are not inside nested brackets
|
||||
let components = splitAssertionComponents(unescaped)
|
||||
guard components.count > 1 else {
|
||||
return RDEPUBCFITextAssertion(exact: unescaped)
|
||||
}
|
||||
return RDEPUBCFITextAssertion(
|
||||
prefix: components.first,
|
||||
exact: components.count > 1 ? components[1] : nil,
|
||||
suffix: components.count > 2 ? components[2] : nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Find the first occurrence of a character not preceded by a backslash
|
||||
private static func firstUnescapedIndex(of character: Character, in string: String) -> String.Index? {
|
||||
var index = string.startIndex
|
||||
while index < string.endIndex {
|
||||
if string[index] == "\\" {
|
||||
// Skip escaped character
|
||||
index = string.index(after: index)
|
||||
if index < string.endIndex {
|
||||
index = string.index(after: index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if string[index] == character {
|
||||
return index
|
||||
}
|
||||
index = string.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Split assertion text by commas, but only those at the top level (not inside brackets)
|
||||
private static func splitAssertionComponents(_ text: String) -> [String] {
|
||||
var components: [String] = []
|
||||
var current = ""
|
||||
var depth = 0
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
let char = text[index]
|
||||
if char == "\\" {
|
||||
current.append(char)
|
||||
index = text.index(after: index)
|
||||
if index < text.endIndex {
|
||||
current.append(text[index])
|
||||
index = text.index(after: index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if char == "[" {
|
||||
depth += 1
|
||||
} else if char == "]" {
|
||||
depth = max(depth - 1, 0)
|
||||
} else if char == "," && depth == 0 {
|
||||
components.append(current)
|
||||
current = ""
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
current.append(char)
|
||||
index = text.index(after: index)
|
||||
}
|
||||
components.append(current)
|
||||
return components
|
||||
}
|
||||
|
||||
/// Unescape CFI text assertion content: \[ → [, \] → ], \\ → \
|
||||
private static func unescapeCFIText(_ text: String) -> String {
|
||||
var result = ""
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
if text[index] == "\\" {
|
||||
let nextIndex = text.index(after: index)
|
||||
if nextIndex < text.endIndex {
|
||||
let nextChar = text[nextIndex]
|
||||
if nextChar == "[" || nextChar == "]" || nextChar == "\\" {
|
||||
result.append(nextChar)
|
||||
index = text.index(after: nextIndex)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Not a recognized escape — keep the backslash
|
||||
result.append(text[index])
|
||||
} else {
|
||||
result.append(text[index])
|
||||
}
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func firstIndexOutsideBrackets(of character: Character, in body: String) -> String.Index? {
|
||||
|
||||
var bracketDepth = 0
|
||||
for index in body.indices {
|
||||
let current = body[index]
|
||||
if current == "[" {
|
||||
bracketDepth += 1
|
||||
} else if current == "]" {
|
||||
bracketDepth = max(bracketDepth - 1, 0)
|
||||
} else if current == character, bracketDepth == 0 {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
|
||||
|
||||
public var steps: [RDEPUBCFIStep]
|
||||
|
||||
public init(steps: [RDEPUBCFIStep] = []) {
|
||||
self.steps = steps
|
||||
}
|
||||
|
||||
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath {
|
||||
var prefix: [RDEPUBCFIStep] = []
|
||||
let upperBound = min(steps.count, other.steps.count)
|
||||
for index in 0..<upperBound {
|
||||
guard steps[index] == other.steps[index] else { break }
|
||||
prefix.append(steps[index])
|
||||
}
|
||||
return RDEPUBCFIPath(steps: prefix)
|
||||
}
|
||||
|
||||
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath {
|
||||
guard prefix.steps.count <= steps.count else { return self }
|
||||
let candidate = Array(steps.prefix(prefix.steps.count))
|
||||
guard candidate == prefix.steps else { return self }
|
||||
return RDEPUBCFIPath(steps: Array(steps.dropFirst(prefix.steps.count)))
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||
|
||||
public var index: Int
|
||||
|
||||
public var idAssertion: String?
|
||||
|
||||
public init(index: Int, idAssertion: String? = nil) {
|
||||
self.index = index
|
||||
self.idAssertion = idAssertion?.rd_nilIfEmpty
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||
public var parent: RDEPUBCFI?
|
||||
public var start: RDEPUBCFI
|
||||
public var end: RDEPUBCFI
|
||||
|
||||
/// Always computed from components — no stale cached value risk.
|
||||
public var rawValue: String {
|
||||
RDEPUBCFISerializer.serializeRange(self)
|
||||
}
|
||||
|
||||
/// Initialize from components. The `rawValue` parameter is ignored — rawValue is always
|
||||
/// computed from the components. To parse a CFI range string, use `RDEPUBCFIParser.parseRange(_:)`.
|
||||
public init(rawValue: String = "", parent: RDEPUBCFI? = nil, start: RDEPUBCFI, end: RDEPUBCFI) {
|
||||
self.parent = parent
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let rawValue = try container.decode(String.self)
|
||||
let parsed = try RDEPUBCFIParser.parseRange(rawValue)
|
||||
self.parent = parsed.parent
|
||||
self.start = parsed.start
|
||||
self.end = parsed.end
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(rawValue)
|
||||
}
|
||||
}
|
||||
@ -1,380 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFIRecoveryResult: Equatable {
|
||||
public enum Confidence: Int, Codable {
|
||||
case exactPath
|
||||
case assertionCalibrated
|
||||
case siblingRecovered
|
||||
case tokenRecovered
|
||||
case fragmentFallback
|
||||
case offsetFallback
|
||||
}
|
||||
|
||||
public var chapterOffset: Int
|
||||
public var confidence: Confidence
|
||||
|
||||
public init(chapterOffset: Int, confidence: Confidence) {
|
||||
self.chapterOffset = chapterOffset
|
||||
self.confidence = confidence
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBCFIRecoveryEngine {
|
||||
static func recover(
|
||||
cfi: RDEPUBCFI,
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
chapterText: String?,
|
||||
fragmentOffsets: [String: Int],
|
||||
fallbackOffset: Int?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult? {
|
||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
||||
|
||||
if let exact = exactPathResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
|
||||
return calibrateIfNeeded(
|
||||
exact,
|
||||
cfi: cfi,
|
||||
chapterText: chapterText,
|
||||
lastOffset: lastOffset
|
||||
)
|
||||
}
|
||||
|
||||
if let sibling = siblingRecoveredResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
|
||||
return calibrateIfNeeded(
|
||||
sibling,
|
||||
cfi: cfi,
|
||||
chapterText: chapterText,
|
||||
lastOffset: lastOffset
|
||||
)
|
||||
}
|
||||
|
||||
if let token = tokenRecoveredResult(
|
||||
cfi: cfi,
|
||||
cfiMap: cfiMap,
|
||||
chapterText: chapterText,
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
) {
|
||||
return token
|
||||
}
|
||||
|
||||
if let fragmentID = resolved.fragmentID,
|
||||
let fragmentOffset = fragmentOffsets[fragmentID]
|
||||
?? cfiMap?.markers.first(where: { $0.fragmentID == fragmentID })?.chapterOffset {
|
||||
return RDEPUBCFIRecoveryResult(
|
||||
chapterOffset: clamp(fragmentOffset, lastOffset: lastOffset),
|
||||
confidence: .fragmentFallback
|
||||
)
|
||||
}
|
||||
|
||||
if let fallbackOffset {
|
||||
return RDEPUBCFIRecoveryResult(
|
||||
chapterOffset: clamp(fallbackOffset, lastOffset: lastOffset),
|
||||
confidence: .offsetFallback
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func exactPathResult(
|
||||
cfi: RDEPUBCFI,
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult? {
|
||||
guard let cfiMap else { return nil }
|
||||
if let marker = cfiMap.marker(matching: cfi.contentPath),
|
||||
let chapterOffset = chapterOffset(for: cfi, marker: marker, lastOffset: lastOffset) {
|
||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
|
||||
}
|
||||
if let pathRange = cfiMap.pathRanges.first(where: { $0.cfiPath == cfi.contentPath }) {
|
||||
let localOffset = max(cfi.characterOffset ?? 0, 0)
|
||||
let chapterOffset = clamp(pathRange.startOffset + min(localOffset, max(pathRange.textNodeLength - 1, 0)), lastOffset: lastOffset)
|
||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func siblingRecoveredResult(
|
||||
cfi: RDEPUBCFI,
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult? {
|
||||
guard let cfiMap,
|
||||
let targetStep = cfi.contentPath.steps.last else { return nil }
|
||||
|
||||
let targetParent = cfi.contentPath.steps.dropLast()
|
||||
let candidates = cfiMap.markers.filter { marker in
|
||||
marker.cfiPath.steps.count == cfi.contentPath.steps.count
|
||||
&& Array(marker.cfiPath.steps.dropLast()) == Array(targetParent)
|
||||
&& marker.chapterOffset != nil
|
||||
}
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
|
||||
let siblingSignature = siblingSignature(for: cfi.contentPath)
|
||||
let best = candidates.min { lhs, rhs in
|
||||
siblingScore(for: lhs, targetStep: targetStep, siblingSignature: siblingSignature)
|
||||
< siblingScore(for: rhs, targetStep: targetStep, siblingSignature: siblingSignature)
|
||||
}
|
||||
|
||||
guard let best,
|
||||
let chapterOffset = chapterOffset(for: cfi, marker: best, lastOffset: lastOffset) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .siblingRecovered)
|
||||
}
|
||||
|
||||
private static func tokenRecoveredResult(
|
||||
cfi: RDEPUBCFI,
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
chapterText: String?,
|
||||
fallbackOffset: Int?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult? {
|
||||
guard let cfiMap,
|
||||
let chapterText,
|
||||
!chapterText.isEmpty,
|
||||
let exact = cfi.textAssertion?.exact,
|
||||
!exact.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let minLength = 2
|
||||
let exactCount = Double(max(exact.count, 1))
|
||||
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
|
||||
let tokenCount = anchor.token.count
|
||||
guard tokenCount >= minLength else { return false }
|
||||
let ratio = Double(tokenCount) / exactCount
|
||||
guard ratio >= 0.3 && ratio <= 3.0 else { return false }
|
||||
let hasPrefixMatch = anchor.token.hasPrefix(exact) || exact.hasPrefix(anchor.token)
|
||||
let hasSuffixMatch = anchor.token.hasSuffix(exact) || exact.hasSuffix(anchor.token)
|
||||
return hasPrefixMatch || hasSuffixMatch
|
||||
}
|
||||
guard !candidateAnchors.isEmpty else { return nil }
|
||||
|
||||
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
|
||||
guard !normalizedText.isEmpty else { return nil }
|
||||
|
||||
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
|
||||
var bestOffset: Int?
|
||||
var bestScore = Int.max
|
||||
|
||||
for anchor in candidateAnchors {
|
||||
let approximateOffset = clamp(anchor.chapterOffset, lastOffset: lastOffset)
|
||||
let calibrated = calibratedOffset(
|
||||
approximateOffset,
|
||||
assertion: cfi.textAssertion,
|
||||
text: chapterText,
|
||||
lastOffset: lastOffset,
|
||||
windowRadius: 768
|
||||
)
|
||||
let score = tokenRecoveryScore(
|
||||
for: anchor,
|
||||
targetPath: cfi.contentPath,
|
||||
approximateOffset: approximateOffset,
|
||||
calibratedOffset: calibrated,
|
||||
preferredOffset: preferredOffset,
|
||||
allAnchors: cfiMap.recoveryMetadata.tokenIndex
|
||||
)
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestOffset = calibrated
|
||||
}
|
||||
}
|
||||
|
||||
guard let bestOffset else { return nil }
|
||||
return RDEPUBCFIRecoveryResult(chapterOffset: bestOffset, confidence: .tokenRecovered)
|
||||
}
|
||||
|
||||
private static func calibrateIfNeeded(
|
||||
_ result: RDEPUBCFIRecoveryResult,
|
||||
cfi: RDEPUBCFI,
|
||||
chapterText: String?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult {
|
||||
guard let chapterText,
|
||||
let assertion = cfi.textAssertion,
|
||||
assertion.exact?.isEmpty == false else {
|
||||
return result
|
||||
}
|
||||
let calibrated = calibratedOffset(
|
||||
result.chapterOffset,
|
||||
assertion: assertion,
|
||||
text: chapterText,
|
||||
lastOffset: lastOffset
|
||||
)
|
||||
if calibrated != result.chapterOffset {
|
||||
return RDEPUBCFIRecoveryResult(chapterOffset: calibrated, confidence: .assertionCalibrated)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func chapterOffset(
|
||||
for cfi: RDEPUBCFI,
|
||||
marker: RDEPUBCFIMarker,
|
||||
lastOffset: Int
|
||||
) -> Int? {
|
||||
guard let baseOffset = marker.chapterOffset else { return nil }
|
||||
let localOffset = max(cfi.characterOffset ?? 0, 0)
|
||||
if let textNodeLength = marker.textNodeLength, textNodeLength > 0 {
|
||||
return clamp(baseOffset + min(localOffset, max(textNodeLength - 1, 0)), lastOffset: lastOffset)
|
||||
}
|
||||
return clamp(baseOffset, lastOffset: lastOffset)
|
||||
}
|
||||
|
||||
private static func siblingSignature(for path: RDEPUBCFIPath) -> String {
|
||||
guard let last = path.steps.last else { return "" }
|
||||
let parent = path.steps.dropLast().map { String($0.index) }.joined(separator: "/")
|
||||
let childIndex = max(last.index / 2, 0)
|
||||
return "\(parent)#\(childIndex)"
|
||||
}
|
||||
|
||||
private static func siblingScore(
|
||||
for marker: RDEPUBCFIMarker,
|
||||
targetStep: RDEPUBCFIStep,
|
||||
siblingSignature: String
|
||||
) -> Int {
|
||||
guard let candidateStep = marker.cfiPath.steps.last else { return Int.max }
|
||||
var score = abs(candidateStep.index - targetStep.index)
|
||||
if marker.domSiblingSignature != siblingSignature {
|
||||
score += 1_000
|
||||
}
|
||||
if marker.fragmentID != nil {
|
||||
score += 100
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
private static func calibratedOffset(
|
||||
_ offset: Int,
|
||||
assertion: RDEPUBCFITextAssertion?,
|
||||
text: String,
|
||||
lastOffset: Int,
|
||||
windowRadius: Int = 512
|
||||
) -> Int {
|
||||
let clampedOffset = clamp(offset, lastOffset: lastOffset)
|
||||
guard let assertion,
|
||||
let exact = assertion.exact,
|
||||
!exact.isEmpty else {
|
||||
return clampedOffset
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else { return clampedOffset }
|
||||
|
||||
let searchStart = max(clampedOffset - windowRadius, 0)
|
||||
let searchEnd = min(clampedOffset + windowRadius + exact.utf16.count, length)
|
||||
guard searchEnd > searchStart else { return clampedOffset }
|
||||
|
||||
let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart)
|
||||
var candidateRange = nsText.range(of: exact, options: [], range: searchRange)
|
||||
var bestOffset: Int?
|
||||
var bestScore = Int.max
|
||||
|
||||
while candidateRange.location != NSNotFound {
|
||||
let score = assertionScore(
|
||||
assertion,
|
||||
candidateLocation: candidateRange.location,
|
||||
exactLength: candidateRange.length,
|
||||
in: nsText,
|
||||
preferredOffset: clampedOffset
|
||||
)
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestOffset = candidateRange.location
|
||||
}
|
||||
|
||||
let nextLocation = candidateRange.location + max(candidateRange.length, 1)
|
||||
let upperBound = searchRange.location + searchRange.length
|
||||
guard nextLocation < upperBound else { break }
|
||||
candidateRange = nsText.range(
|
||||
of: exact,
|
||||
options: [],
|
||||
range: NSRange(location: nextLocation, length: upperBound - nextLocation)
|
||||
)
|
||||
}
|
||||
|
||||
guard let bestOffset, bestScore < Int.max else {
|
||||
return clampedOffset
|
||||
}
|
||||
return clamp(bestOffset, lastOffset: lastOffset)
|
||||
}
|
||||
|
||||
private static func assertionScore(
|
||||
_ assertion: RDEPUBCFITextAssertion,
|
||||
candidateLocation: Int,
|
||||
exactLength: Int,
|
||||
in text: NSString,
|
||||
preferredOffset: Int
|
||||
) -> Int {
|
||||
var score = abs(candidateLocation - preferredOffset)
|
||||
if let prefix = assertion.prefix, !prefix.isEmpty {
|
||||
let prefixStart = max(candidateLocation - prefix.utf16.count, 0)
|
||||
let availableLength = candidateLocation - prefixStart
|
||||
let candidatePrefix = availableLength > 0
|
||||
? text.substring(with: NSRange(location: prefixStart, length: availableLength))
|
||||
: ""
|
||||
score += candidatePrefix.hasSuffix(prefix) ? 0 : 10_000
|
||||
}
|
||||
if let suffix = assertion.suffix, !suffix.isEmpty {
|
||||
let suffixStart = min(candidateLocation + exactLength, text.length)
|
||||
let suffixLength = min(suffix.utf16.count, text.length - suffixStart)
|
||||
let candidateSuffix = suffixLength > 0
|
||||
? text.substring(with: NSRange(location: suffixStart, length: suffixLength))
|
||||
: ""
|
||||
score += candidateSuffix == suffix ? 0 : 10_000
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
|
||||
min(max(offset, 0), max(lastOffset, 0))
|
||||
}
|
||||
|
||||
private static func tokenRecoveryScore(
|
||||
for anchor: RDEPUBCFITokenAnchor,
|
||||
targetPath: RDEPUBCFIPath,
|
||||
approximateOffset: Int,
|
||||
calibratedOffset: Int,
|
||||
preferredOffset: Int?,
|
||||
allAnchors: [RDEPUBCFITokenAnchor]
|
||||
) -> Int {
|
||||
var score = abs(calibratedOffset - approximateOffset)
|
||||
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
|
||||
|
||||
if let preferredOffset {
|
||||
score += abs(approximateOffset - preferredOffset)
|
||||
let expectedOccurrence = nearestOccurrence(
|
||||
forToken: anchor.token,
|
||||
preferredOffset: preferredOffset,
|
||||
anchors: allAnchors
|
||||
)
|
||||
score += abs(anchor.occurrence - expectedOccurrence) * 256
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private static func nearestOccurrence(
|
||||
forToken token: String,
|
||||
preferredOffset: Int,
|
||||
anchors: [RDEPUBCFITokenAnchor]
|
||||
) -> Int {
|
||||
anchors
|
||||
.filter { $0.token == token }
|
||||
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
|
||||
.occurrence ?? 0
|
||||
}
|
||||
|
||||
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
|
||||
let prefix = lhs.commonPrefix(with: rhs).steps.count
|
||||
let remainingLHS = lhs.steps.dropFirst(prefix)
|
||||
let remainingRHS = rhs.steps.dropFirst(prefix)
|
||||
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
|
||||
partial + abs(pair.0.index - pair.1.index)
|
||||
}
|
||||
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFIResolverResult: Equatable {
|
||||
|
||||
public var href: String?
|
||||
|
||||
public var fileIndex: Int?
|
||||
|
||||
public var chapterOffset: Int?
|
||||
|
||||
public var fragmentID: String?
|
||||
|
||||
public init(href: String? = nil, fileIndex: Int? = nil, chapterOffset: Int? = nil, fragmentID: String? = nil) {
|
||||
self.href = href
|
||||
self.fileIndex = fileIndex
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBCFIResolver {
|
||||
|
||||
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
|
||||
|
||||
// EPUB CFI spec: package path structure is /6/2[spine]/2n[manifest-id]
|
||||
// Manifest step is always the 3rd step (index 2) if present, or the last step with an id assertion.
|
||||
let manifestStep: RDEPUBCFIStep?
|
||||
if cfi.packagePath.steps.count >= 3 {
|
||||
manifestStep = cfi.packagePath.steps[2]
|
||||
} else {
|
||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })
|
||||
}
|
||||
let href = manifestStep?.idAssertion
|
||||
|
||||
// Spine step is the 2nd step (index 1) in the package path
|
||||
let fileIndex: Int?
|
||||
if cfi.packagePath.steps.count >= 2 {
|
||||
fileIndex = max((cfi.packagePath.steps[1].index / 2) - 1, 0)
|
||||
} else {
|
||||
fileIndex = nil
|
||||
}
|
||||
|
||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })?.idAssertion
|
||||
return RDEPUBCFIResolverResult(
|
||||
href: href,
|
||||
fileIndex: fileIndex,
|
||||
chapterOffset: cfi.characterOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFISerializer {
|
||||
|
||||
public static func serialize(_ cfi: RDEPUBCFI) -> String {
|
||||
|
||||
var body = serializePath(cfi.packagePath)
|
||||
|
||||
let content = serializePath(cfi.contentPath)
|
||||
|
||||
if !content.isEmpty || cfi.characterOffset != nil {
|
||||
body += "!" + content
|
||||
}
|
||||
|
||||
if let characterOffset = cfi.characterOffset {
|
||||
body += ":\(max(characterOffset, 0))"
|
||||
}
|
||||
|
||||
if let sideBias = cfi.sideBias {
|
||||
body += ";s=\(sideBias.rawValue)"
|
||||
}
|
||||
|
||||
if let assertion = cfi.textAssertion {
|
||||
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
|
||||
}
|
||||
return "epubcfi(\(body))"
|
||||
}
|
||||
|
||||
public static func serializeRange(_ range: RDEPUBCFIRange) -> String {
|
||||
let canonical = canonicalRangeComponents(for: range)
|
||||
let parentBody = serializeRangeParent(canonical.parent)
|
||||
let startBody = serializeRangeTerminal(
|
||||
canonical.start,
|
||||
relativeToPackagePath: canonical.parent.packagePath,
|
||||
contentPrefix: canonical.parent.contentPath
|
||||
)
|
||||
let endBody = serializeRangeTerminal(
|
||||
canonical.end,
|
||||
relativeToPackagePath: canonical.parent.packagePath,
|
||||
contentPrefix: canonical.parent.contentPath
|
||||
)
|
||||
return "epubcfi(\(parentBody),\(startBody),\(endBody))"
|
||||
}
|
||||
|
||||
public static func serializePath(_ path: RDEPUBCFIPath) -> String {
|
||||
guard !path.steps.isEmpty else { return "" }
|
||||
return path.steps.map { step in
|
||||
if let idAssertion = step.idAssertion {
|
||||
return "/\(step.index)[\(idAssertion)]"
|
||||
}
|
||||
return "/\(step.index)"
|
||||
}.joined()
|
||||
}
|
||||
|
||||
private static func canonicalRangeComponents(for range: RDEPUBCFIRange) -> (
|
||||
parent: RDEPUBCFI,
|
||||
start: RDEPUBCFI,
|
||||
end: RDEPUBCFI
|
||||
) {
|
||||
if let parent = range.parent {
|
||||
return (parent, range.start, range.end)
|
||||
}
|
||||
|
||||
let sharedPackage = range.start.packagePath.commonPrefix(with: range.end.packagePath)
|
||||
let sharedContent = range.start.contentPath.commonPrefix(with: range.end.contentPath)
|
||||
let parent = RDEPUBCFI(
|
||||
packagePath: sharedPackage,
|
||||
contentPath: sharedContent,
|
||||
characterOffset: nil,
|
||||
sideBias: nil,
|
||||
textAssertion: nil
|
||||
)
|
||||
return (parent, range.start, range.end)
|
||||
}
|
||||
|
||||
private static func serializeRangeParent(_ parent: RDEPUBCFI) -> String {
|
||||
var body = serializePath(parent.packagePath)
|
||||
let content = serializePath(parent.contentPath)
|
||||
if !content.isEmpty {
|
||||
body += "!" + content
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
private static func serializeRangeTerminal(
|
||||
_ cfi: RDEPUBCFI,
|
||||
relativeToPackagePath parentPackagePath: RDEPUBCFIPath,
|
||||
contentPrefix: RDEPUBCFIPath
|
||||
) -> String {
|
||||
let relativePackage = cfi.packagePath.droppingPrefix(parentPackagePath)
|
||||
let relativeContent = cfi.contentPath.droppingPrefix(contentPrefix)
|
||||
|
||||
var body = serializePath(relativePackage)
|
||||
let content = serializePath(relativeContent)
|
||||
if !content.isEmpty || cfi.characterOffset != nil || !relativePackage.steps.isEmpty {
|
||||
body += content
|
||||
}
|
||||
if let characterOffset = cfi.characterOffset {
|
||||
body += ":\(max(characterOffset, 0))"
|
||||
}
|
||||
if let sideBias = cfi.sideBias {
|
||||
body += ";s=\(sideBias.rawValue)"
|
||||
}
|
||||
if let assertion = cfi.textAssertion {
|
||||
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
|
||||
}
|
||||
return body
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
||||
|
||||
public var prefix: String?
|
||||
|
||||
public var exact: String?
|
||||
|
||||
public var suffix: String?
|
||||
|
||||
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
|
||||
self.prefix = Self.trimmedOrNil(prefix)
|
||||
self.exact = Self.trimmedOrNil(exact)
|
||||
self.suffix = Self.trimmedOrNil(suffix)
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
internal extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -1,333 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight")
|
||||
|
||||
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline")
|
||||
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
|
||||
public var bookIdentifier: String?
|
||||
|
||||
public var location: RDEPUBLocation
|
||||
|
||||
public var text: String
|
||||
|
||||
public var rangeInfo: String?
|
||||
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||||
case bookmark
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
case highlight
|
||||
case annotate
|
||||
}
|
||||
|
||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
|
||||
public var kind: String
|
||||
|
||||
public var href: String
|
||||
|
||||
public var start: Int
|
||||
|
||||
public var end: Int
|
||||
|
||||
public init(href: String, start: Int, end: Int) {
|
||||
self.kind = "text-offset"
|
||||
self.href = href
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public var nsRange: NSRange? {
|
||||
guard end > start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
public func jsonString() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
||||
guard let string,
|
||||
let data = string.data(using: .utf8),
|
||||
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
||||
rangeInfo.kind == "text-offset",
|
||||
rangeInfo.end > rangeInfo.start else {
|
||||
return nil
|
||||
}
|
||||
return rangeInfo
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBHighlight: Codable, Equatable {
|
||||
|
||||
public var id: String
|
||||
|
||||
public var bookIdentifier: String?
|
||||
|
||||
public var location: RDEPUBLocation
|
||||
|
||||
public var text: String
|
||||
|
||||
public var rangeInfo: String?
|
||||
|
||||
public var style: RDEPUBHighlightStyle
|
||||
|
||||
public var color: String
|
||||
|
||||
public var note: String?
|
||||
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var uiColor: UIColor {
|
||||
UIColor(rdHexString: color, alpha: 0.35)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
case location
|
||||
case text
|
||||
case rangeInfo
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case createdAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
||||
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
||||
try container.encode(location, forKey: .location)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(highlight: self)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBBookmark: Codable, Equatable {
|
||||
|
||||
public var id: String
|
||||
|
||||
public var bookIdentifier: String?
|
||||
|
||||
public var location: RDEPUBLocation
|
||||
|
||||
public var rangeInfo: String?
|
||||
|
||||
public var chapterTitle: String?
|
||||
|
||||
public var note: String?
|
||||
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
rangeInfo: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var kind: RDEPUBAnnotationKind
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String?
|
||||
public var rangeInfo: String?
|
||||
public var color: String?
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
kind: RDEPUBAnnotationKind,
|
||||
location: RDEPUBLocation,
|
||||
text: String? = nil,
|
||||
rangeInfo: String? = nil,
|
||||
color: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.kind = kind
|
||||
self.location = location
|
||||
self.text = text?.nilIfEmpty
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.color = color?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public init(highlight: RDEPUBHighlight) {
|
||||
self.init(
|
||||
id: highlight.id,
|
||||
bookIdentifier: highlight.bookIdentifier,
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
location: highlight.location,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
color: highlight.color,
|
||||
chapterTitle: nil,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public init(bookmark: RDEPUBBookmark) {
|
||||
self.init(
|
||||
id: bookmark.id,
|
||||
bookIdentifier: bookmark.bookIdentifier,
|
||||
kind: .bookmark,
|
||||
location: bookmark.location,
|
||||
text: nil,
|
||||
rangeInfo: bookmark.rangeInfo,
|
||||
color: nil,
|
||||
chapterTitle: bookmark.chapterTitle,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var bookmark: RDEPUBBookmark? {
|
||||
guard kind == .bookmark else { return nil }
|
||||
return RDEPUBBookmark(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
rangeInfo: rangeInfo,
|
||||
chapterTitle: chapterTitle,
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var highlight: RDEPUBHighlight? {
|
||||
guard kind == .highlight || kind == .underline,
|
||||
let text else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBHighlight(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo,
|
||||
style: kind == .underline ? .underline : .highlight,
|
||||
color: color ?? "#F8E16C",
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
||||
|
||||
case chapterEnd
|
||||
|
||||
case frameLimit
|
||||
|
||||
case blockBoundary
|
||||
|
||||
case attachmentBoundary
|
||||
|
||||
case semanticBoundary
|
||||
}
|
||||
|
||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
|
||||
case image
|
||||
|
||||
case generic
|
||||
|
||||
case footnote
|
||||
}
|
||||
|
||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
|
||||
public var breakReason: RDEPUBTextPageBreakReason
|
||||
|
||||
public var blockRange: NSRange?
|
||||
|
||||
public var attachmentRanges: [NSRange]
|
||||
|
||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
|
||||
public var trailingFragmentID: String?
|
||||
|
||||
public var diagnostics: [String]
|
||||
|
||||
public init(
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange? = nil,
|
||||
attachmentRanges: [NSRange] = [],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
||||
blockKinds: [RDEPUBTextBlockKind] = [],
|
||||
semanticHints: [RDEPUBTextSemanticHint] = [],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
||||
trailingFragmentID: String? = nil,
|
||||
diagnostics: [String] = []
|
||||
) {
|
||||
self.breakReason = breakReason
|
||||
self.blockRange = blockRange
|
||||
self.attachmentRanges = attachmentRanges
|
||||
self.attachmentKinds = attachmentKinds
|
||||
self.blockKinds = blockKinds
|
||||
self.semanticHints = semanticHints
|
||||
self.attachmentPlacements = attachmentPlacements
|
||||
self.trailingFragmentID = trailingFragmentID
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBChapterInfo: Codable, Equatable {
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var title: String
|
||||
|
||||
public var pageCount: Int
|
||||
|
||||
public init(spineIndex: Int, title: String, pageCount: Int) {
|
||||
self.spineIndex = spineIndex
|
||||
self.title = title
|
||||
self.pageCount = pageCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBPage: Codable, Equatable {
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var chapterIndex: Int
|
||||
|
||||
public var pageIndexInChapter: Int
|
||||
|
||||
public var totalPagesInChapter: Int
|
||||
|
||||
public var chapterTitle: String
|
||||
|
||||
public var fixedSpread: EPUBFixedSpread?
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int,
|
||||
pageIndexInChapter: Int,
|
||||
totalPagesInChapter: Int,
|
||||
chapterTitle: String,
|
||||
fixedSpread: EPUBFixedSpread?
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterIndex = chapterIndex
|
||||
self.pageIndexInChapter = pageIndexInChapter
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.fixedSpread = fixedSpread
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var href: String
|
||||
|
||||
public var title: String
|
||||
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.pageSpread = pageSpread
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBFixedSpread: Codable, Equatable {
|
||||
|
||||
public var resources: [EPUBFixedSpreadResource]
|
||||
|
||||
public init(resources: [EPUBFixedSpreadResource]) {
|
||||
self.resources = resources
|
||||
}
|
||||
|
||||
public var primaryResource: EPUBFixedSpreadResource {
|
||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
||||
}
|
||||
|
||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
||||
resources.contains { resource in
|
||||
normalizer(resource.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
let resources = spine.enumerated().map { index, item in
|
||||
EPUBFixedSpreadResource(
|
||||
spineIndex: index,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageSpread: item.pageSpread
|
||||
)
|
||||
}
|
||||
|
||||
guard spreadEnabled, resources.count > 1 else {
|
||||
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
||||
}
|
||||
|
||||
var spreads: [EPUBFixedSpread] = []
|
||||
var cursor = 0
|
||||
|
||||
while cursor < resources.count {
|
||||
let current = resources[cursor]
|
||||
|
||||
guard cursor + 1 < resources.count else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
break
|
||||
}
|
||||
|
||||
let next = resources[cursor + 1]
|
||||
if shouldPair(current: current, next: next) {
|
||||
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
||||
cursor += 2
|
||||
} else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
return spreads
|
||||
}
|
||||
|
||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
||||
}
|
||||
|
||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
||||
if current.pageSpread == .center || next.pageSpread == .center {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (current.pageSpread, next.pageSpread) {
|
||||
case (.left, .right), (.right, .left):
|
||||
return true
|
||||
case (.left, .left), (.right, .right):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBLocation: Codable, Equatable {
|
||||
|
||||
public var bookIdentifier: String?
|
||||
|
||||
public var href: String
|
||||
|
||||
public var progression: Double
|
||||
|
||||
public var lastProgression: Double?
|
||||
|
||||
public var fragment: String?
|
||||
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public var cfi: String?
|
||||
|
||||
public var lastCFI: String?
|
||||
|
||||
public var rangeCFI: String?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
||||
cfi: String? = nil,
|
||||
lastCFI: String? = nil,
|
||||
rangeCFI: String? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
self.cfi = cfi?.nilIfEmpty
|
||||
self.lastCFI = lastCFI?.nilIfEmpty
|
||||
self.rangeCFI = rangeCFI?.nilIfEmpty
|
||||
}
|
||||
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
return progression
|
||||
}
|
||||
return Self.clamp((progression + end) / 2.0)
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(1, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
|
||||
public var href: String
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var progression: Double
|
||||
|
||||
public var lastProgression: Double?
|
||||
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.spineIndex = spineIndex
|
||||
self.progression = progression
|
||||
self.lastProgression = lastProgression
|
||||
self.fragment = fragment
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
|
||||
public var visiblePageNumber: Int
|
||||
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
resources: [RDEPUBViewportResource],
|
||||
visiblePageNumber: Int,
|
||||
chapterIndex: Int? = nil,
|
||||
isFixedLayout: Bool
|
||||
) {
|
||||
self.resources = resources
|
||||
self.visiblePageNumber = visiblePageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
self.isFixedLayout = isFixedLayout
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
|
||||
public var location: RDEPUBLocation
|
||||
|
||||
public var viewport: RDEPUBViewport
|
||||
|
||||
public var pageNumber: Int
|
||||
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public init(
|
||||
location: RDEPUBLocation,
|
||||
viewport: RDEPUBViewport,
|
||||
pageNumber: Int,
|
||||
chapterIndex: Int? = nil
|
||||
) {
|
||||
self.location = location
|
||||
self.viewport = viewport
|
||||
self.pageNumber = pageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNoteDetector {
|
||||
|
||||
private static let noteTokens = [
|
||||
"noteref",
|
||||
"footnote",
|
||||
"endnote",
|
||||
"rearnote",
|
||||
"fnref",
|
||||
"fn-",
|
||||
"fn_",
|
||||
"note",
|
||||
"annotation"
|
||||
]
|
||||
|
||||
public static func makeReferenceIfLikelyNote(
|
||||
sourceHref: String,
|
||||
sourceCFI: String?,
|
||||
targetHref: String,
|
||||
targetFragment: String?,
|
||||
targetCFI: String?,
|
||||
targetElementHTML: String?
|
||||
) -> RDEPUBNoteReference? {
|
||||
|
||||
let haystack = [
|
||||
targetHref,
|
||||
targetFragment ?? "",
|
||||
targetElementHTML ?? ""
|
||||
].joined(separator: " ").lowercased()
|
||||
|
||||
guard noteTokens.contains(where: { haystack.contains($0) }) ||
|
||||
hasEPUBType("footnote", in: targetElementHTML) ||
|
||||
hasEPUBType("endnote", in: targetElementHTML) ||
|
||||
hasEPUBType("rearnote", in: targetElementHTML) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBNoteReference(
|
||||
sourceHref: sourceHref,
|
||||
sourceCFI: sourceCFI,
|
||||
targetHref: targetHref,
|
||||
targetFragment: targetFragment,
|
||||
targetCFI: targetCFI,
|
||||
label: targetFragment,
|
||||
kind: kind(from: haystack)
|
||||
)
|
||||
}
|
||||
|
||||
public static func kind(from text: String) -> RDEPUBNoteKind {
|
||||
let lowered = text.lowercased()
|
||||
if lowered.contains("endnote") { return .endnote }
|
||||
if lowered.contains("rearnote") { return .rearnote }
|
||||
if lowered.contains("footnote") || lowered.contains("fn") { return .footnote }
|
||||
return .unknown
|
||||
}
|
||||
|
||||
private static func hasEPUBType(_ type: String, in html: String?) -> Bool {
|
||||
guard let html else { return false }
|
||||
let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"#
|
||||
return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNoteKind: String, Codable, Equatable {
|
||||
|
||||
case footnote
|
||||
|
||||
case endnote
|
||||
|
||||
case rearnote
|
||||
|
||||
case unknown
|
||||
}
|
||||
|
||||
public struct RDEPUBNoteReference: Codable, Equatable {
|
||||
|
||||
public var sourceHref: String
|
||||
|
||||
public var sourceCFI: String?
|
||||
|
||||
public var targetHref: String
|
||||
|
||||
public var targetFragment: String?
|
||||
|
||||
public var targetCFI: String?
|
||||
|
||||
public var label: String?
|
||||
|
||||
public var kind: RDEPUBNoteKind
|
||||
|
||||
public init(
|
||||
sourceHref: String,
|
||||
sourceCFI: String? = nil,
|
||||
targetHref: String,
|
||||
targetFragment: String? = nil,
|
||||
targetCFI: String? = nil,
|
||||
label: String? = nil,
|
||||
kind: RDEPUBNoteKind = .unknown
|
||||
) {
|
||||
self.sourceHref = sourceHref
|
||||
self.sourceCFI = sourceCFI
|
||||
self.targetHref = targetHref
|
||||
self.targetFragment = targetFragment
|
||||
self.targetCFI = targetCFI
|
||||
self.label = label?.rd_nilIfEmpty
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBResolvedNote: Equatable {
|
||||
|
||||
public var reference: RDEPUBNoteReference
|
||||
|
||||
public var sourceLocation: RDEPUBLocation?
|
||||
|
||||
public var targetLocation: RDEPUBLocation
|
||||
|
||||
public var title: String?
|
||||
|
||||
public var html: String
|
||||
|
||||
public var plainText: String
|
||||
|
||||
public init(
|
||||
reference: RDEPUBNoteReference,
|
||||
sourceLocation: RDEPUBLocation? = nil,
|
||||
targetLocation: RDEPUBLocation,
|
||||
title: String? = nil,
|
||||
html: String,
|
||||
plainText: String
|
||||
) {
|
||||
self.reference = reference
|
||||
self.sourceLocation = sourceLocation
|
||||
self.targetLocation = targetLocation
|
||||
self.title = title?.rd_nilIfEmpty
|
||||
self.html = html
|
||||
self.plainText = plainText
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBNoteResolver {
|
||||
|
||||
private let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(resourceResolver: RDEPUBResourceResolver) {
|
||||
self.resourceResolver = resourceResolver
|
||||
}
|
||||
|
||||
public func resolveInternalLink(
|
||||
sourceHref: String,
|
||||
sourceCFI: String?,
|
||||
targetLocation: RDEPUBLocation,
|
||||
sourceLocation: RDEPUBLocation? = nil,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
) -> RDEPUBResolvedNote? {
|
||||
guard let normalizedTarget = resourceResolver.normalizedLocation(
|
||||
targetLocation,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: targetLocation.bookIdentifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fragment = normalizedTarget.fragment
|
||||
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
|
||||
sourceHref: sourceHref,
|
||||
sourceCFI: sourceCFI,
|
||||
targetHref: normalizedTarget.href,
|
||||
targetFragment: fragment,
|
||||
targetCFI: normalizedTarget.cfi,
|
||||
targetElementHTML: elementHTML
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sanitizedHTML = stripBacklinks(from: elementHTML)
|
||||
let plainText = plainText(from: sanitizedHTML)
|
||||
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBResolvedNote(
|
||||
reference: reference,
|
||||
sourceLocation: sourceLocation,
|
||||
targetLocation: normalizedTarget,
|
||||
title: title(for: reference),
|
||||
html: sanitizedHTML,
|
||||
plainText: plainText
|
||||
)
|
||||
}
|
||||
|
||||
public func extractElementHTML(href: String, fragment: String?) -> String? {
|
||||
guard let fragment,
|
||||
!fragment.isEmpty,
|
||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||||
let html = chapterHTML(at: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return elementHTML(withID: fragment, in: html)
|
||||
}
|
||||
|
||||
/// 读取章节 HTML:优先经加密资源 provider 解密,明文文件保持原直读路径
|
||||
private func chapterHTML(at fileURL: URL) -> String? {
|
||||
if let provided = resourceResolver.providedResourceData(at: fileURL) {
|
||||
return String(data: provided, encoding: .utf8)
|
||||
?? String(data: provided, encoding: .utf16)
|
||||
}
|
||||
return try? String(contentsOf: fileURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func elementHTML(withID id: String, in html: String) -> String? {
|
||||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
||||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||||
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
|
||||
let startRange = Range(match.range, in: html),
|
||||
let tagRange = Range(match.range(at: 1), in: html) else {
|
||||
return nil
|
||||
}
|
||||
let tagName = String(html[tagRange])
|
||||
guard !String(html[startRange]).hasSuffix("/>") else {
|
||||
return String(html[startRange])
|
||||
}
|
||||
guard let endRange = matchingEndTagRange(
|
||||
tagName: tagName,
|
||||
in: html,
|
||||
after: startRange.upperBound
|
||||
) else {
|
||||
return String(html[startRange])
|
||||
}
|
||||
return String(html[startRange.lowerBound..<endRange.upperBound])
|
||||
}
|
||||
|
||||
private func stripBacklinks(from html: String) -> String {
|
||||
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||||
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
|
||||
}
|
||||
|
||||
private func plainText(from html: String) -> String {
|
||||
let withoutScripts = html
|
||||
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
|
||||
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
|
||||
return withoutTags
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func title(for reference: RDEPUBNoteReference) -> String {
|
||||
switch reference.kind {
|
||||
case .footnote:
|
||||
return "脚注"
|
||||
case .endnote:
|
||||
return "尾注"
|
||||
case .rearnote:
|
||||
return "后注"
|
||||
case .unknown:
|
||||
return "注释"
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingEndTagRange(
|
||||
tagName: String,
|
||||
in html: String,
|
||||
after startIndex: String.Index
|
||||
) -> Range<String.Index>? {
|
||||
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
|
||||
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
|
||||
var depth = 1
|
||||
for match in regex.matches(in: html, options: [], range: searchRange) {
|
||||
guard let range = Range(match.range, in: html) else { continue }
|
||||
let tag = String(html[range])
|
||||
if tag.hasPrefix("</") {
|
||||
depth -= 1
|
||||
} else if !tag.hasSuffix("/>") {
|
||||
depth += 1
|
||||
}
|
||||
if depth == 0 {
|
||||
return range
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBAsset: String {
|
||||
|
||||
case rangyCoreScript = "rangy-core"
|
||||
|
||||
case rangySerializerScript = "rangy-serializer"
|
||||
|
||||
case cssInjectorScript = "cssInjector"
|
||||
|
||||
case weReadAPIScript = "WeReadApi"
|
||||
|
||||
case bridgeScript = "epub-bridge"
|
||||
|
||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
||||
|
||||
case wxReadDefaultCSS = "wxread-default"
|
||||
|
||||
case wxReadReplaceCSS = "wxread-replace"
|
||||
|
||||
case wxReadDarkCSS = "wxread-dark"
|
||||
|
||||
case wxReadLatinReplaceCSS = "wxread-replace-latin"
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
|
||||
return "js"
|
||||
case .fixedLayoutTemplate:
|
||||
return "html"
|
||||
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
|
||||
return "css"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBAssetRepository {
|
||||
|
||||
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
|
||||
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
|
||||
var content = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
assertionFailure("Missing EPUB asset: \(asset.rawValue).\(asset.fileExtension)")
|
||||
return ""
|
||||
}
|
||||
|
||||
for (token, value) in replacements {
|
||||
content = content.replacingOccurrences(of: "{{\(token)}}", with: value)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private static var resourceBundle: Bundle {
|
||||
if let bundle = resolvedBundle {
|
||||
return bundle
|
||||
}
|
||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
||||
}
|
||||
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] {
|
||||
if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
@ -1,53 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBFixedLayoutTemplate {
|
||||
|
||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
||||
|
||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageType: String
|
||||
if request.spread.resources.count == 1 {
|
||||
pageType = "single"
|
||||
} else if request.spread.resources.count == 2 {
|
||||
pageType = index == 0 ? "left" : "right"
|
||||
} else {
|
||||
pageType = "center"
|
||||
}
|
||||
|
||||
return """
|
||||
<div class=\"ss-fixed-viewport\" data-page-type=\"\(pageType)\">
|
||||
<iframe class=\"ss-fixed-page\" data-page-type=\"\(pageType)\" scrolling=\"no\" src=\"\(url)\"></iframe>
|
||||
</div>
|
||||
"""
|
||||
}.joined()
|
||||
|
||||
let background = request.backgroundColorCSS ?? "#FFFFFF"
|
||||
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
|
||||
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
|
||||
let insetTop = Int(request.contentInset.top.rounded(.down))
|
||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
||||
|
||||
return RDEPUBAssetRepository.string(
|
||||
for: .fixedLayoutTemplate,
|
||||
replacements: [
|
||||
"BACKGROUND": background,
|
||||
"INSET_TOP": String(insetTop),
|
||||
"INSET_RIGHT": String(insetRight),
|
||||
"INSET_BOTTOM": String(insetBottom),
|
||||
"INSET_LEFT": String(insetLeft),
|
||||
"VIEWPORT_WIDTH": String(viewportWidth),
|
||||
"VIEWPORT_HEIGHT": String(viewportHeight),
|
||||
"PANES": panes,
|
||||
"FIT_MODE": request.fit.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,188 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||
|
||||
case progressionChanged = "ssReaderProgressionChanged"
|
||||
|
||||
case selectionChanged = "ssReaderSelectionChanged"
|
||||
|
||||
case internalLink = "ssReaderInternalLink"
|
||||
|
||||
case externalLink = "ssReaderExternalLink"
|
||||
|
||||
case javaScriptError = "ssReaderJSError"
|
||||
|
||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||
|
||||
case imageDidTap = "ssReaderImageDidTap"
|
||||
|
||||
case footnoteDidTap = "ssReaderFootnoteDidTap"
|
||||
}
|
||||
|
||||
enum RDEPUBJavaScriptBridge {
|
||||
|
||||
static var messageNames: [String] {
|
||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
||||
}
|
||||
|
||||
static var userScript: String {
|
||||
[
|
||||
RDEPUBAssetRepository.string(for: .rangyCoreScript),
|
||||
RDEPUBAssetRepository.string(for: .rangySerializerScript),
|
||||
RDEPUBAssetRepository.string(for: .cssInjectorScript),
|
||||
RDEPUBAssetRepository.string(for: .weReadAPIScript),
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
replacements: [
|
||||
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
|
||||
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
|
||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue,
|
||||
"IMAGE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue,
|
||||
"FOOTNOTE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue
|
||||
]
|
||||
)
|
||||
].joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
||||
let pageStride = max(1, request.presentation.viewportSize.width)
|
||||
let searchScript = applySearchScript(for: request.searchPresentation)
|
||||
|
||||
return """
|
||||
(function() {
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.applySharedTheme({
|
||||
backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)),
|
||||
textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)),
|
||||
includeFrames: false
|
||||
});
|
||||
window.WeReadApi.applyPagination(`\(style)`);
|
||||
window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
|
||||
window.WeReadApi.clearHighlights();
|
||||
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
||||
window.WeReadApi.scrollToLocation(
|
||||
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
|
||||
\(request.pageIndex),
|
||||
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
|
||||
);
|
||||
} else {
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
window.WeReadApi.reportProgression();
|
||||
return true;
|
||||
})();
|
||||
\(searchScript)
|
||||
"""
|
||||
}
|
||||
|
||||
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
|
||||
"""
|
||||
(function() {
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.applySharedTheme({
|
||||
backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)),
|
||||
textColor: null,
|
||||
includeFrames: true
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
(function() {
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.setSearchPresentation(\(payload));
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
static func resolveDecorationsScript() -> String {
|
||||
"""
|
||||
(function() {
|
||||
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
|
||||
return window.WeReadApi.resolveDecorations();
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
}
|
||||
|
||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
||||
highlights.compactMap { highlight in
|
||||
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
|
||||
return [
|
||||
"id": highlight.id,
|
||||
"rangeInfo": rangeInfo,
|
||||
"color": highlight.color,
|
||||
"style": highlight.style.rawValue
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
|
||||
guard let location else { return nil }
|
||||
return [
|
||||
"progression": location.progression,
|
||||
"lastProgression": location.lastProgression ?? location.progression,
|
||||
"fragment": location.fragment as Any
|
||||
]
|
||||
}
|
||||
|
||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
||||
guard let presentation else { return nil }
|
||||
return [
|
||||
"keyword": presentation.keyword,
|
||||
"resources": presentation.resources.map { resource in
|
||||
[
|
||||
"href": resource.href,
|
||||
"matchCount": resource.matchCount,
|
||||
"activeLocalMatchIndex": resource.activeLocalMatchIndex as Any
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
||||
guard let object else { return fallback }
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return fallback
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
/// Encodes a string value as a safe JavaScript string literal.
|
||||
///
|
||||
/// Uses JSON serialization to handle escaping of quotes, backslashes, control
|
||||
/// characters, and Unicode, then strips the array wrapper to produce a standalone
|
||||
/// JS string literal (including surrounding quotes).
|
||||
///
|
||||
/// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`.
|
||||
/// - Returns: A JavaScript string literal safe for inline embedding in JS source.
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
guard JSONSerialization.isValidJSONObject([value]),
|
||||
let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
|
||||
let arrayString = String(data: data, encoding: .utf8) else {
|
||||
return "null"
|
||||
}
|
||||
// JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]`
|
||||
// to yield `"value"` — a valid JS string literal with all special characters escaped.
|
||||
return String(arrayString.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
@ -1,214 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBLayout: String, Codable {
|
||||
case reflowable
|
||||
case fixed
|
||||
}
|
||||
|
||||
public enum RDEPUBReadingProfile: String, Codable {
|
||||
case webInteractive
|
||||
case webFixedLayout
|
||||
case textReflowable
|
||||
}
|
||||
|
||||
public enum RDEPUBReadingProgression: String, Codable {
|
||||
case ltr
|
||||
case rtl
|
||||
case auto
|
||||
}
|
||||
|
||||
public enum RDEPUBPageSpread: String, Codable {
|
||||
case left
|
||||
case right
|
||||
case center
|
||||
}
|
||||
|
||||
/// EPUB 渲染朝向(rendition:orientation / iBooks display-options)。
|
||||
public enum RDEPUBOrientation: String, Codable {
|
||||
case portrait
|
||||
case landscape
|
||||
case auto
|
||||
}
|
||||
|
||||
public struct RDEPUBMetadata: Codable, Equatable {
|
||||
|
||||
public var identifier: String?
|
||||
|
||||
public var title: String
|
||||
|
||||
public var author: String?
|
||||
|
||||
public var language: String?
|
||||
|
||||
public var version: String?
|
||||
|
||||
public var layout: RDEPUBLayout
|
||||
|
||||
public var spread: String?
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression
|
||||
|
||||
/// 渲染朝向;nil = 未声明(不锁定,跟随系统)。
|
||||
/// 来源优先级:OPF rendition:orientation > META-INF/com.apple.ibooks.display-options.xml。
|
||||
public var orientation: RDEPUBOrientation?
|
||||
|
||||
public init(
|
||||
identifier: String? = nil,
|
||||
title: String = "",
|
||||
author: String? = nil,
|
||||
language: String? = nil,
|
||||
version: String? = nil,
|
||||
layout: RDEPUBLayout = .reflowable,
|
||||
spread: String? = nil,
|
||||
readingProgression: RDEPUBReadingProgression = .auto,
|
||||
orientation: RDEPUBOrientation? = nil
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.language = language
|
||||
self.version = version
|
||||
self.layout = layout
|
||||
self.spread = spread
|
||||
self.readingProgression = readingProgression
|
||||
self.orientation = orientation
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
|
||||
public var id: String
|
||||
|
||||
public var href: String
|
||||
|
||||
public var mediaType: String
|
||||
|
||||
public var properties: [String]
|
||||
|
||||
public var fallback: String?
|
||||
|
||||
public var mediaOverlay: String?
|
||||
|
||||
public var title: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
href: String,
|
||||
mediaType: String,
|
||||
properties: [String] = [],
|
||||
fallback: String? = nil,
|
||||
mediaOverlay: String? = nil,
|
||||
title: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.href = href
|
||||
self.mediaType = mediaType
|
||||
self.properties = properties
|
||||
self.fallback = fallback
|
||||
self.mediaOverlay = mediaOverlay
|
||||
self.title = title
|
||||
}
|
||||
|
||||
public var isNavigationDocument: Bool {
|
||||
properties.contains("nav")
|
||||
}
|
||||
|
||||
public var isNCX: Bool {
|
||||
mediaType == "application/x-dtbncx+xml"
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
|
||||
public var idref: String
|
||||
|
||||
public var href: String
|
||||
|
||||
public var mediaType: String
|
||||
|
||||
public var title: String
|
||||
|
||||
public var linear: Bool
|
||||
|
||||
public var properties: [String]
|
||||
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
public var layout: RDEPUBLayout?
|
||||
|
||||
public init(
|
||||
idref: String,
|
||||
href: String,
|
||||
mediaType: String,
|
||||
title: String,
|
||||
linear: Bool = true,
|
||||
properties: [String] = [],
|
||||
pageSpread: RDEPUBPageSpread? = nil,
|
||||
layout: RDEPUBLayout? = nil
|
||||
) {
|
||||
self.idref = idref
|
||||
self.href = href
|
||||
self.mediaType = mediaType
|
||||
self.title = title
|
||||
self.linear = linear
|
||||
self.properties = properties
|
||||
self.pageSpread = pageSpread
|
||||
self.layout = layout
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||
|
||||
public var title: String
|
||||
|
||||
public var href: String
|
||||
|
||||
public var children: [EPUBTableOfContentsItem]
|
||||
|
||||
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
|
||||
self.title = title
|
||||
self.href = href
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBParserError: LocalizedError {
|
||||
|
||||
case archiveOpenFailed(URL)
|
||||
|
||||
case missingContainerXML
|
||||
|
||||
case missingRootFile
|
||||
|
||||
case invalidRootFilePath(String)
|
||||
|
||||
case invalidXML(URL)
|
||||
|
||||
case missingManifestItem(idref: String)
|
||||
|
||||
case emptySpine
|
||||
|
||||
case invalidArchiveEntryPath(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .archiveOpenFailed(let url):
|
||||
return "无法打开 EPUB 压缩包: \(url.lastPathComponent)"
|
||||
case .missingContainerXML:
|
||||
return "EPUB 缺少 META-INF/container.xml"
|
||||
case .missingRootFile:
|
||||
return "container.xml 中未找到 rootfile"
|
||||
case .invalidRootFilePath(let path):
|
||||
return "OPF 路径无效: \(path)"
|
||||
case .invalidXML(let url):
|
||||
return "XML 解析失败: \(url.lastPathComponent)"
|
||||
case .missingManifestItem(let idref):
|
||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||
case .emptySpine:
|
||||
return "OPF spine 为空"
|
||||
case .invalidArchiveEntryPath(let path):
|
||||
return "归档条目路径非法: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
|
||||
public var containerSize: CGSize
|
||||
|
||||
public var pagesPerScreen: Int
|
||||
|
||||
public var safeAreaInsets: UIEdgeInsets
|
||||
|
||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
||||
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public init(
|
||||
containerSize: CGSize,
|
||||
pagesPerScreen: Int = 1,
|
||||
safeAreaInsets: UIEdgeInsets = .zero,
|
||||
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
||||
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
|
||||
) {
|
||||
self.containerSize = containerSize
|
||||
self.pagesPerScreen = max(1, pagesPerScreen)
|
||||
self.safeAreaInsets = safeAreaInsets
|
||||
self.userInterfaceIdiom = userInterfaceIdiom
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
}
|
||||
|
||||
public var viewportSize: CGSize {
|
||||
let width: CGFloat
|
||||
if pagesPerScreen > 1 {
|
||||
width = containerSize.width / CGFloat(pagesPerScreen)
|
||||
} else {
|
||||
width = containerSize.width
|
||||
}
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// Content insets that account for safe area (Dynamic Island / notch) on iPhone.
|
||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for top/bottom
|
||||
/// to ensure content is never hidden under the Dynamic Island or home indicator.
|
||||
public var safeReflowableContentInsets: UIEdgeInsets {
|
||||
let top = max(safeAreaInsets.top, reflowableContentInsets.top)
|
||||
let bottom = max(safeAreaInsets.bottom, reflowableContentInsets.bottom)
|
||||
let left = max(safeAreaInsets.left, reflowableContentInsets.left)
|
||||
let right = max(safeAreaInsets.right, reflowableContentInsets.right)
|
||||
return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
|
||||
}
|
||||
|
||||
/// Fixed layout content insets that respect safe areas on all devices including iPhone.
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
let horizontalInsets = max(insets.left, insets.right)
|
||||
insets.left = horizontalInsets
|
||||
insets.right = horizontalInsets
|
||||
return insets
|
||||
}
|
||||
}
|
||||
@ -1,379 +0,0 @@
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public final class RDEPUBPaginator: NSObject {
|
||||
|
||||
private var parser: RDEPUBParser?
|
||||
|
||||
private weak var hostingView: UIView?
|
||||
|
||||
private var presentation = RDEPUBPresentationStyle(
|
||||
viewportSize: .zero,
|
||||
contentInsets: .zero,
|
||||
fontSize: 16,
|
||||
lineHeightMultiple: 1.5
|
||||
)
|
||||
|
||||
private var completion: (([Int]) -> Void)?
|
||||
|
||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
||||
|
||||
private var pageCounts: [Int] = []
|
||||
|
||||
private var measurementIndices: [Int] = []
|
||||
|
||||
private var currentMeasurementOffset = 0
|
||||
|
||||
private var pendingMeasurementValue = 1
|
||||
|
||||
private var measurementPass = 0
|
||||
|
||||
private var activeSessionID = UUID()
|
||||
|
||||
private let debugScope = "PaginatorWebView"
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = self
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.bounces = false
|
||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.isUserInteractionEnabled = false
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
return webView
|
||||
}()
|
||||
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanupMeasurementState()
|
||||
webView.navigationDelegate = nil
|
||||
webView.stopLoading()
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
completion: @escaping ([Int]) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.hostingView = hostingView
|
||||
self.presentation = presentation
|
||||
self.completion = completion
|
||||
self.singlePageCountCompletion = nil
|
||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
||||
self.measurementIndices = Array(parser.spine.indices)
|
||||
self.currentMeasurementOffset = 0
|
||||
self.activeSessionID = UUID()
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)")
|
||||
|
||||
guard parser.metadata.layout != .fixed else {
|
||||
completion(self.pageCounts)
|
||||
return
|
||||
}
|
||||
|
||||
webView.navigationDelegate = self
|
||||
if webView.superview == nil {
|
||||
hostingView.addSubview(webView)
|
||||
}
|
||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
completion: @escaping ([Int]) -> Void
|
||||
) {
|
||||
calculate(
|
||||
parser: parser,
|
||||
hostingView: hostingView,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple
|
||||
),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
hostingView: UIView,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
completion: @escaping (Int) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.hostingView = hostingView
|
||||
self.presentation = presentation
|
||||
self.completion = nil
|
||||
self.singlePageCountCompletion = completion
|
||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
||||
self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : []
|
||||
self.currentMeasurementOffset = 0
|
||||
self.activeSessionID = UUID()
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)")
|
||||
|
||||
guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else {
|
||||
completion(1)
|
||||
return
|
||||
}
|
||||
|
||||
webView.navigationDelegate = self
|
||||
if webView.superview == nil {
|
||||
hostingView.addSubview(webView)
|
||||
}
|
||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
hostingView: UIView,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
completion: @escaping (Int) -> Void
|
||||
) {
|
||||
calculateSingleSpinePageCount(
|
||||
parser: parser,
|
||||
spineIndex: spineIndex,
|
||||
hostingView: hostingView,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple
|
||||
),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) -> String {
|
||||
RDEPUBStyleSheetBuilder.injectPaginationCSS(
|
||||
into: html,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func measureNextSpineItem() {
|
||||
guard let parser else {
|
||||
finishIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard currentMeasurementOffset < measurementIndices.count else {
|
||||
finishIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
let currentSpineIndex = measurementIndices[currentMeasurementOffset]
|
||||
|
||||
let item = parser.spine[currentSpineIndex]
|
||||
guard isRenderablePage(item: item),
|
||||
let opfDirectoryURL = parser.opfDirectoryURL else {
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
let fileURL = opfDirectoryURL.appendingPathComponent(item.href)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
pendingMeasurementValue = 1
|
||||
measurementPass = 0
|
||||
let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL)
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
||||
}
|
||||
|
||||
private func currentSpineIndexForMeasurement() -> Int? {
|
||||
measurementIndices[safe: currentMeasurementOffset]
|
||||
}
|
||||
|
||||
private func finishIfNeeded() {
|
||||
if let singlePageCountCompletion {
|
||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
||||
singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1))
|
||||
self.singlePageCountCompletion = nil
|
||||
cleanupMeasurementState()
|
||||
return
|
||||
}
|
||||
guard let completion else { return }
|
||||
completion(pageCounts)
|
||||
self.completion = nil
|
||||
cleanupMeasurementState()
|
||||
}
|
||||
|
||||
private func cleanupMeasurementState() {
|
||||
parser = nil
|
||||
hostingView = nil
|
||||
completion = nil
|
||||
singlePageCountCompletion = nil
|
||||
measurementIndices = []
|
||||
currentMeasurementOffset = 0
|
||||
pendingMeasurementValue = 1
|
||||
measurementPass = 0
|
||||
activeSessionID = UUID()
|
||||
webView.stopLoading()
|
||||
webView.navigationDelegate = nil
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
private var measurementPassValues: [Int] = []
|
||||
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
|
||||
guard measurementPass < delays.count else {
|
||||
guard sessionID == activeSessionID,
|
||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||
return
|
||||
}
|
||||
let finalValue = max(1, pendingMeasurementValue)
|
||||
|
||||
if measurementPassValues.count >= 2 {
|
||||
let minVal = measurementPassValues.min() ?? 1
|
||||
let maxVal = measurementPassValues.max() ?? 1
|
||||
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
|
||||
}
|
||||
}
|
||||
pageCounts[currentSpineIndex] = finalValue
|
||||
currentMeasurementOffset += 1
|
||||
measurementPassValues = []
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
let delay = delays[measurementPass]
|
||||
measurementPass += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self, self.activeSessionID == sessionID else { return }
|
||||
self.measureCurrentDocument(sessionID: sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
private func measureCurrentDocument(sessionID: UUID) {
|
||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
||||
|
||||
webView.evaluateJavaScript(script) { [weak self] value, _ in
|
||||
guard let self else { return }
|
||||
guard self.activeSessionID == sessionID,
|
||||
self.currentSpineIndexForMeasurement() != nil else {
|
||||
return
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||
self.measurementPassValues.append(number.intValue)
|
||||
} else if let intValue = value as? Int {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||
self.measurementPassValues.append(intValue)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||
self.scheduleMeasurementPass()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
decisionHandler(.allow)
|
||||
} else {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||
guard currentSpineIndexForMeasurement() != nil else { return }
|
||||
scheduleMeasurementPass()
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||||
throw RDEPUBParserError.invalidXML(containerURL)
|
||||
}
|
||||
|
||||
let delegate = ContainerXMLParserDelegate()
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
|
||||
guard parser.parse() else {
|
||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL)
|
||||
}
|
||||
|
||||
guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else {
|
||||
throw RDEPUBParserError.missingRootFile
|
||||
}
|
||||
|
||||
return rootFilePath
|
||||
}
|
||||
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
|
||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
if fileManager.fileExists(atPath: containerURL.path) {
|
||||
return extractionURL
|
||||
}
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
}
|
||||
|
||||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
||||
throw RDEPUBParserError.archiveOpenFailed(epubURL)
|
||||
}
|
||||
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
do {
|
||||
for entry in archive {
|
||||
if shouldSkipEntry(entry.path) { continue }
|
||||
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
if isCriticalEntry(entry.path) {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
private func isCriticalEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
return lowercased == "mimetype"
|
||||
|| lowercased.hasPrefix("meta-inf/")
|
||||
|| lowercased.hasSuffix(".opf")
|
||||
}
|
||||
|
||||
private func shouldSkipEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
if lowercased.hasPrefix("__macosx/") { return true }
|
||||
if lowercased.hasPrefix(".ds_store") { return true }
|
||||
if lowercased.contains("/.ds_store") { return true }
|
||||
if lowercased.hasSuffix("/thumbs.db") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
|
||||
if entryPath.hasPrefix("/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||||
if components.contains(where: { $0 == ".." }) {
|
||||
return nil
|
||||
}
|
||||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||||
let standardizedDest = destinationURL.standardizedFileURL.path
|
||||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||||
|
||||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||||
return nil
|
||||
}
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
||||
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
||||
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let slug = epubURL.deletingPathExtension().lastPathComponent
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
let signature = String(format: "%.0f", modifiedAt)
|
||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
extractionRootURL = nil
|
||||
opfURL = nil
|
||||
resetPublicationState()
|
||||
}
|
||||
|
||||
func resetPublicationState() {
|
||||
metadata = RDEPUBMetadata()
|
||||
manifest = [:]
|
||||
spine = []
|
||||
tableOfContents = []
|
||||
}
|
||||
}
|
||||
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "rootfile", rootFilePath == nil else {
|
||||
return
|
||||
}
|
||||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。
|
||||
final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private(set) var orientationLock: String?
|
||||
|
||||
private var isCapturingOrientationOption = false
|
||||
private var buffer = ""
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "option" else { return }
|
||||
let optionName = (attributeDict["name"] ?? "").lowercased()
|
||||
if optionName == "orientation-lock" {
|
||||
isCapturingOrientationOption = true
|
||||
buffer = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
guard isCapturingOrientationOption else { return }
|
||||
buffer += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "option", isCapturingOrientationOption else { return }
|
||||
isCapturingOrientationOption = false
|
||||
let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty, orientationLock == nil {
|
||||
orientationLock = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,407 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
let publicationLayout = packageDocument.metadata.layout
|
||||
|
||||
let spineItems = try packageDocument.spineReferences.map { reference in
|
||||
guard let manifestItem = packageDocument.manifestByID[reference.idref] else {
|
||||
throw RDEPUBParserError.missingManifestItem(idref: reference.idref)
|
||||
}
|
||||
|
||||
let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL)
|
||||
let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackTitle = normalizedHref
|
||||
.split(separator: "/")
|
||||
.last
|
||||
.map(String.init)
|
||||
.map { $0.replacingOccurrences(of: ".xhtml", with: "") }
|
||||
.map { $0.replacingOccurrences(of: ".html", with: "") }
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
?? reference.idref
|
||||
|
||||
let effectiveLayout: RDEPUBLayout?
|
||||
if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") {
|
||||
effectiveLayout = .fixed
|
||||
} else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") {
|
||||
effectiveLayout = .reflowable
|
||||
} else {
|
||||
effectiveLayout = publicationLayout == .fixed ? .fixed : nil
|
||||
}
|
||||
|
||||
return RDEPUBSpineItem(
|
||||
idref: reference.idref,
|
||||
href: normalizedHref,
|
||||
mediaType: manifestItem.mediaType,
|
||||
title: title?.isEmpty == false ? title! : fallbackTitle,
|
||||
linear: reference.linear,
|
||||
properties: reference.properties,
|
||||
pageSpread: reference.pageSpread,
|
||||
layout: effectiveLayout
|
||||
)
|
||||
}
|
||||
|
||||
guard !spineItems.isEmpty else {
|
||||
throw RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
return spineItems
|
||||
}
|
||||
}
|
||||
|
||||
struct OPFPackageDocument {
|
||||
var metadata: RDEPUBMetadata
|
||||
var manifest: [RDEPUBManifestItem]
|
||||
var manifestByID: [String: RDEPUBManifestItem]
|
||||
var spineReferences: [OPFSpineReference]
|
||||
|
||||
var ncxItem: RDEPUBManifestItem?
|
||||
|
||||
var navigationItem: RDEPUBManifestItem?
|
||||
}
|
||||
|
||||
struct OPFSpineReference {
|
||||
|
||||
var idref: String
|
||||
|
||||
var linear: Bool
|
||||
|
||||
var properties: [String]
|
||||
|
||||
var pageSpread: RDEPUBPageSpread?
|
||||
}
|
||||
|
||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private var metadata = RDEPUBMetadata()
|
||||
|
||||
private var manifestItems: [RDEPUBManifestItem] = []
|
||||
|
||||
private var spineReferences: [OPFSpineReference] = []
|
||||
|
||||
private let xmlContext = XMLParserContext()
|
||||
|
||||
private var uniqueIdentifierID: String?
|
||||
|
||||
private var currentIdentifierElementID: String?
|
||||
|
||||
private var currentMetaProperty: String?
|
||||
|
||||
private var identifierByID: [String: String] = [:]
|
||||
|
||||
private var manifestTitleByID: [String: String] = [:]
|
||||
|
||||
private var currentMetaRefinesID: String?
|
||||
|
||||
private var ncxID: String?
|
||||
|
||||
func packageDocument() -> OPFPackageDocument {
|
||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||||
metadata.identifier = fallbackIdentifier
|
||||
}
|
||||
metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let manifest = manifestItems.map { item in
|
||||
var updatedItem = item
|
||||
updatedItem.title = manifestTitleByID[item.id]
|
||||
return updatedItem
|
||||
}
|
||||
// 部分历史 EPUB 的 OPF manifest 含重复 id。`uniqueKeysWithValues` 会在
|
||||
// 此处触发运行时断言,导致整本书无法打开;spine 按 OPF 顺序解析,故保留
|
||||
// 第一个声明即可维持其原始引用语义。
|
||||
let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in
|
||||
guard !item.id.isEmpty, result[item.id] == nil else { return }
|
||||
result[item.id] = item
|
||||
}
|
||||
|
||||
return OPFPackageDocument(
|
||||
metadata: metadata,
|
||||
manifest: manifest,
|
||||
manifestByID: manifestByID,
|
||||
spineReferences: spineReferences,
|
||||
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
|
||||
navigationItem: manifest.first(where: { $0.isNavigationDocument })
|
||||
)
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = xmlContext.startElement(named: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "package":
|
||||
metadata.version = attributeDict["version"]
|
||||
uniqueIdentifierID = attributeDict["unique-identifier"]
|
||||
case "identifier":
|
||||
if currentSection == .metadata {
|
||||
currentIdentifierElementID = attributeDict["id"]
|
||||
}
|
||||
case "item":
|
||||
guard currentSection == .manifest,
|
||||
let id = attributeDict["id"],
|
||||
let href = attributeDict["href"],
|
||||
let mediaType = attributeDict["media-type"] else {
|
||||
return
|
||||
}
|
||||
manifestItems.append(
|
||||
RDEPUBManifestItem(
|
||||
id: id,
|
||||
href: href,
|
||||
mediaType: mediaType,
|
||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||||
fallback: attributeDict["fallback"],
|
||||
mediaOverlay: attributeDict["media-overlay"]
|
||||
)
|
||||
)
|
||||
case "itemref":
|
||||
guard currentSection == .spine, let idref = attributeDict["idref"] else {
|
||||
return
|
||||
}
|
||||
spineReferences.append(
|
||||
OPFSpineReference(
|
||||
idref: idref,
|
||||
linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no",
|
||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||||
pageSpread: pageSpread(from: attributeDict)
|
||||
)
|
||||
)
|
||||
case "meta":
|
||||
currentMetaProperty = attributeDict["property"] ?? attributeDict["name"]
|
||||
currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:))
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:layout",
|
||||
let value = attributeDict["content"] {
|
||||
metadata.layout = layout(from: value)
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:spread" {
|
||||
metadata.spread = attributeDict["content"]
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:orientation",
|
||||
let value = attributeDict["content"] {
|
||||
metadata.orientation = orientation(from: value)
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "title",
|
||||
let refinesID = currentMetaRefinesID,
|
||||
let value = attributeDict["content"],
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if name == "spine" {
|
||||
if let direction = attributeDict["page-progression-direction"]?.lowercased() {
|
||||
metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto)
|
||||
}
|
||||
ncxID = attributeDict["toc"]
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
xmlContext.appendCharacters(string)
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
let text = xmlContext.trimmedCharacters
|
||||
|
||||
switch (currentSection, name) {
|
||||
case (.metadata, "identifier"):
|
||||
if let identifierID = currentIdentifierElementID, !text.isEmpty {
|
||||
identifierByID[identifierID] = text
|
||||
if identifierID == uniqueIdentifierID {
|
||||
metadata.identifier = text
|
||||
}
|
||||
} else if metadata.identifier == nil, !text.isEmpty {
|
||||
metadata.identifier = text
|
||||
}
|
||||
currentIdentifierElementID = nil
|
||||
case (.metadata, "title"):
|
||||
if metadata.title.isEmpty, !text.isEmpty {
|
||||
metadata.title = text
|
||||
}
|
||||
case (.metadata, "creator"):
|
||||
if metadata.author == nil, !text.isEmpty {
|
||||
metadata.author = text
|
||||
}
|
||||
case (.metadata, "language"):
|
||||
if metadata.language == nil, !text.isEmpty {
|
||||
metadata.language = text
|
||||
}
|
||||
case (.metadata, "meta"):
|
||||
applyMetaValue(text)
|
||||
currentMetaProperty = nil
|
||||
currentMetaRefinesID = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
xmlContext.endElement()
|
||||
}
|
||||
|
||||
private var currentSection: XMLSection {
|
||||
if xmlContext.containsElement(named: "manifest") {
|
||||
return .manifest
|
||||
}
|
||||
if xmlContext.containsElement(named: "spine") {
|
||||
return .spine
|
||||
}
|
||||
if xmlContext.containsElement(named: "metadata") {
|
||||
return .metadata
|
||||
}
|
||||
return .other
|
||||
}
|
||||
|
||||
private func applyMetaValue(_ value: String) {
|
||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
||||
return
|
||||
}
|
||||
|
||||
switch property {
|
||||
case "rendition:layout":
|
||||
metadata.layout = layout(from: normalizedValue)
|
||||
case "rendition:spread":
|
||||
metadata.spread = normalizedValue
|
||||
case "rendition:orientation":
|
||||
metadata.orientation = orientation(from: normalizedValue)
|
||||
case "dcterms:identifier", "identifier":
|
||||
if metadata.identifier == nil {
|
||||
metadata.identifier = normalizedValue
|
||||
}
|
||||
case "title":
|
||||
if let refinesID = currentMetaRefinesID {
|
||||
manifestTitleByID[refinesID] = normalizedValue
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
||||
if let properties = attributes["properties"]?.lowercased() {
|
||||
if properties.contains("page-spread-left") {
|
||||
return .left
|
||||
}
|
||||
if properties.contains("page-spread-right") {
|
||||
return .right
|
||||
}
|
||||
if properties.contains("page-spread-center") {
|
||||
return .center
|
||||
}
|
||||
}
|
||||
|
||||
if let legacySpread = attributes["page-spread"]?.lowercased() {
|
||||
switch legacySpread {
|
||||
case "left":
|
||||
return .left
|
||||
case "right":
|
||||
return .right
|
||||
case "center":
|
||||
return .center
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
|
||||
private func orientation(from rawValue: String) -> RDEPUBOrientation? {
|
||||
switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
case "portrait":
|
||||
return .portrait
|
||||
case "landscape":
|
||||
return .landscape
|
||||
case "auto":
|
||||
return .auto
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum XMLSection {
|
||||
case metadata
|
||||
case manifest
|
||||
case spine
|
||||
case other
|
||||
}
|
||||
|
||||
enum XMLName {
|
||||
|
||||
static func localName(from rawName: String) -> String {
|
||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||||
}
|
||||
|
||||
static func tokenize(_ rawValue: String?) -> [String] {
|
||||
guard let rawValue else { return [] }
|
||||
return rawValue
|
||||
.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" })
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
static func refinedID(from rawValue: String) -> String? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("#") else {
|
||||
return nil
|
||||
}
|
||||
return String(trimmed.dropFirst())
|
||||
}
|
||||
}
|
||||
|
||||
final class XMLParserContext {
|
||||
|
||||
private var elementStack: [String] = []
|
||||
|
||||
private var currentCharacters = ""
|
||||
|
||||
var trimmedCharacters: String {
|
||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func startElement(named rawName: String) -> String {
|
||||
let name = XMLName.localName(from: rawName)
|
||||
elementStack.append(name)
|
||||
currentCharacters = ""
|
||||
return name
|
||||
}
|
||||
|
||||
func appendCharacters(_ string: String) {
|
||||
currentCharacters += string
|
||||
}
|
||||
|
||||
func endElement() {
|
||||
if !elementStack.isEmpty {
|
||||
_ = elementStack.removeLast()
|
||||
}
|
||||
currentCharacters = ""
|
||||
}
|
||||
|
||||
func containsElement(named name: String) -> Bool {
|
||||
elementStack.contains(name)
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
public func readingProfile() -> RDEPUBReadingProfile {
|
||||
if metadata.layout == .fixed {
|
||||
return .webFixedLayout
|
||||
}
|
||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
||||
}
|
||||
|
||||
public func hasInteractiveContent() -> Bool {
|
||||
let interactiveMediaTypes = [
|
||||
"application/javascript",
|
||||
"text/javascript",
|
||||
"application/ecmascript"
|
||||
]
|
||||
if manifest.values.contains(where: { item in
|
||||
interactiveMediaTypes.contains(item.mediaType.lowercased()) || item.properties.contains("scripted")
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
|
||||
guard let html = htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
if containsInteractiveMarkup(html) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func containsInteractiveMarkup(_ html: String) -> Bool {
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
let dynamicSVGPattern = #"<svg\b[^>]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"#
|
||||
return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
||||
manifest[id]
|
||||
}
|
||||
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let target = href.components(separatedBy: "#").first ?? href
|
||||
return manifest.values.first { item in
|
||||
normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target
|
||||
}
|
||||
}
|
||||
|
||||
public func href(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else { return nil }
|
||||
return spine[index].href
|
||||
}
|
||||
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
guard let opfDirectoryURL else { return nil }
|
||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
||||
guard !path.isEmpty else { return nil }
|
||||
let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL
|
||||
guard let rootURL = extractionRootURL?.standardizedFileURL else {
|
||||
return resolvedURL
|
||||
}
|
||||
guard resolvedURL.path.hasPrefix(rootURL.path) else {
|
||||
return nil
|
||||
}
|
||||
return resolvedURL
|
||||
}
|
||||
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !normalizedPath.isEmpty else { return nil }
|
||||
guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return nil
|
||||
}
|
||||
var components = URLComponents()
|
||||
components.scheme = RDEPUBResourceURLSchemeHandler.scheme
|
||||
components.host = RDEPUBResourceURLSchemeHandler.host
|
||||
components.percentEncodedPath = "/" + encodedPath
|
||||
return components.url
|
||||
}
|
||||
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
||||
return nil
|
||||
}
|
||||
let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
return fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func coverImage() -> UIImage? {
|
||||
let coverCandidates = manifest.values.filter { item in
|
||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
||||
}
|
||||
for item in coverCandidates {
|
||||
if let fileURL = fileURL(forRelativePath: item.href),
|
||||
let data = resourceData(at: fileURL),
|
||||
let image = UIImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else {
|
||||
return nil
|
||||
}
|
||||
return htmlString(forRelativePath: spine[index].href)
|
||||
}
|
||||
|
||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
if let provided = providedResourceData(at: fileURL) {
|
||||
return String(data: provided, encoding: .utf8)
|
||||
?? String(data: provided, encoding: .utf16)
|
||||
}
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
||||
return href
|
||||
}
|
||||
let prefix = directoryURL.standardizedFileURL.path + "/"
|
||||
if resolvedURL.path.hasPrefix(prefix) {
|
||||
return String(resolvedURL.path.dropFirst(prefix.count))
|
||||
}
|
||||
return href
|
||||
}
|
||||
}
|
||||
@ -1,303 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
|
||||
if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) {
|
||||
let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href)
|
||||
let items = parseNCXDocumentItems(at: ncxURL)
|
||||
if !items.isEmpty {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
if let navItem = packageDocument.navigationItem {
|
||||
let navURL = opfDirectoryURL.appendingPathComponent(navItem.href)
|
||||
let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent())
|
||||
if !items.isEmpty {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
||||
}
|
||||
|
||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in
|
||||
self?.normalizeTOCHref(href, relativeTo: baseURL)
|
||||
}
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else {
|
||||
return []
|
||||
}
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
||||
let parser = XMLParser(contentsOf: navURL) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in
|
||||
self?.normalizeTOCHref(href, relativeTo: currentBaseURL)
|
||||
}
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else {
|
||||
return []
|
||||
}
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
|
||||
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = components.first.map(String.init) ?? href
|
||||
let fragment = components.count > 1 ? String(components[1]) : nil
|
||||
|
||||
let normalizedPath: String
|
||||
if pathPart.isEmpty {
|
||||
normalizedPath = ""
|
||||
} else {
|
||||
normalizedPath = normalize(href: pathPart, relativeTo: baseURL)
|
||||
}
|
||||
|
||||
if let fragment, !fragment.isEmpty {
|
||||
if normalizedPath.isEmpty {
|
||||
return "#\(fragment)"
|
||||
}
|
||||
return "\(normalizedPath)#\(fragment)"
|
||||
}
|
||||
|
||||
return normalizedPath.isEmpty ? nil : normalizedPath
|
||||
}
|
||||
}
|
||||
|
||||
private final class TOCNode {
|
||||
var title = ""
|
||||
var href: String?
|
||||
var children: [TOCNode] = []
|
||||
|
||||
func asItem() -> EPUBTableOfContentsItem? {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let href, !href.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return EPUBTableOfContentsItem(
|
||||
title: trimmedTitle.isEmpty ? href : trimmedTitle,
|
||||
href: href,
|
||||
children: children.compactMap { $0.asItem() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TOCTreeBuilder {
|
||||
|
||||
private var nodeStack: [TOCNode] = []
|
||||
|
||||
private var rootNodes: [TOCNode] = []
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
rootNodes.compactMap { $0.asItem() }
|
||||
}
|
||||
|
||||
func beginNode() {
|
||||
nodeStack.append(TOCNode())
|
||||
}
|
||||
|
||||
func updateCurrentHref(_ href: String?) {
|
||||
nodeStack.last?.href = href
|
||||
}
|
||||
|
||||
func appendCurrentTitle(_ text: String) {
|
||||
nodeStack.last?.title += text
|
||||
}
|
||||
|
||||
func endNode() {
|
||||
guard let node = nodeStack.popLast() else {
|
||||
return
|
||||
}
|
||||
if let parent = nodeStack.last {
|
||||
parent.children.append(node)
|
||||
} else {
|
||||
rootNodes.append(node)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
nodeStack.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
private let treeBuilder = TOCTreeBuilder()
|
||||
private var readingLabelText = false
|
||||
|
||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
||||
self.baseURL = baseURL
|
||||
self.hrefNormalizer = hrefNormalizer
|
||||
}
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
treeBuilder.items
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "navPoint":
|
||||
treeBuilder.beginNode()
|
||||
case "content":
|
||||
guard let src = attributeDict["src"] else {
|
||||
return
|
||||
}
|
||||
treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL))
|
||||
case "text":
|
||||
readingLabelText = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if readingLabelText {
|
||||
treeBuilder.appendCurrentTitle(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "text":
|
||||
readingLabelText = false
|
||||
case "navPoint":
|
||||
treeBuilder.endNode()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
private var insideTOCNav = false
|
||||
private var listDepth = 0
|
||||
private let treeBuilder = TOCTreeBuilder()
|
||||
private var collectingAnchorText = false
|
||||
|
||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
||||
self.baseURL = baseURL
|
||||
self.hrefNormalizer = hrefNormalizer
|
||||
}
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
treeBuilder.items
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "nav":
|
||||
insideTOCNav = isTOCNav(attributes: attributeDict)
|
||||
case "ol":
|
||||
if insideTOCNav {
|
||||
listDepth += 1
|
||||
}
|
||||
case "li":
|
||||
if insideTOCNav, listDepth > 0 {
|
||||
treeBuilder.beginNode()
|
||||
}
|
||||
case "a":
|
||||
guard insideTOCNav else {
|
||||
return
|
||||
}
|
||||
collectingAnchorText = true
|
||||
if let href = attributeDict["href"] {
|
||||
treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if collectingAnchorText {
|
||||
treeBuilder.appendCurrentTitle(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "a":
|
||||
collectingAnchorText = false
|
||||
case "li":
|
||||
guard insideTOCNav else {
|
||||
break
|
||||
}
|
||||
treeBuilder.endNode()
|
||||
case "ol":
|
||||
if insideTOCNav {
|
||||
listDepth = max(0, listDepth - 1)
|
||||
}
|
||||
case "nav":
|
||||
insideTOCNav = false
|
||||
listDepth = 0
|
||||
treeBuilder.reset()
|
||||
collectingAnchorText = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||||
return true
|
||||
}
|
||||
if let type = attributes["type"]?.lowercased(), type.contains("toc") {
|
||||
return true
|
||||
}
|
||||
if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBParser {
|
||||
|
||||
public internal(set) var metadata = RDEPUBMetadata()
|
||||
|
||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
||||
|
||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
||||
|
||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
||||
|
||||
public internal(set) var extractionRootURL: URL?
|
||||
|
||||
public internal(set) var opfURL: URL?
|
||||
|
||||
public var opfDirectoryURL: URL? {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// 加密资源数据提供者;nil 时所有资源按明文直读。
|
||||
/// 必须在 `parse(epubURL:)` 之前设置(推荐通过 `RDEPUBReaderDependencies.live(resourceDataProvider:)` 注入)。
|
||||
public var resourceDataProvider: RDEPUBResourceDataProvider? {
|
||||
didSet {
|
||||
guard resourceDataProvider != nil else { return }
|
||||
RDEPUBResourceAccessRegistry.register(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
||||
resourceDataProvider?.resourceData(at: fileURL)
|
||||
}
|
||||
|
||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
||||
public func resourceData(at fileURL: URL) -> Data? {
|
||||
if let provided = providedResourceData(at: fileURL) {
|
||||
return provided
|
||||
}
|
||||
return try? Data(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Cache Management
|
||||
|
||||
/// The base directory used for EPUB extraction caches.
|
||||
public static var extractionCacheBaseDirectory: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Removes all EPUB extraction caches from disk.
|
||||
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
|
||||
/// or during cleanup when the reader is no longer needed.
|
||||
public static func cleanExtractionCache() throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
try fileManager.removeItem(at: baseURL)
|
||||
}
|
||||
|
||||
/// Evicts extraction caches until total disk usage is below the given threshold,
|
||||
/// removing least-recently-accessed directories first.
|
||||
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
|
||||
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
)
|
||||
|
||||
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
|
||||
var totalSize: UInt64 = 0
|
||||
|
||||
for directoryURL in contents {
|
||||
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
|
||||
resourceValues.isDirectory == true else {
|
||||
continue
|
||||
}
|
||||
let directorySize = directoryURL.directorySize
|
||||
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
|
||||
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
|
||||
totalSize += directorySize
|
||||
}
|
||||
|
||||
guard totalSize > maxBytes else { return }
|
||||
|
||||
entries.sort { $0.accessDate < $1.accessDate }
|
||||
|
||||
for entry in entries {
|
||||
guard totalSize > maxBytes else { break }
|
||||
try? fileManager.removeItem(at: entry.url)
|
||||
totalSize -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
|
||||
public func parse(epubURL: URL) throws {
|
||||
reset()
|
||||
|
||||
let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL)
|
||||
extractionRootURL = extractionURL
|
||||
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
guard FileManager.default.fileExists(atPath: containerURL.path) else {
|
||||
throw RDEPUBParserError.missingContainerXML
|
||||
}
|
||||
|
||||
let rootFilePath = try parseContainerRootFile(at: containerURL)
|
||||
let packageURL = extractionURL.appendingPathComponent(rootFilePath)
|
||||
guard FileManager.default.fileExists(atPath: packageURL.path) else {
|
||||
throw RDEPUBParserError.invalidRootFilePath(rootFilePath)
|
||||
}
|
||||
|
||||
try parseOPF(at: packageURL)
|
||||
|
||||
// 兜底:OPF 未声明朝向时,回退读取 iBooks display-options
|
||||
if metadata.orientation == nil {
|
||||
let displayOptionsURL = extractionURL
|
||||
.appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml")
|
||||
if let orientation = parseIBooksOrientation(at: displayOptionsURL) {
|
||||
metadata.orientation = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `com.apple.ibooks.display-options.xml` 中的 orientation-lock。
|
||||
/// 结构:platform[@name] / option[@name="orientation-lock"] 文本值。
|
||||
private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? {
|
||||
guard FileManager.default.fileExists(atPath: url.path),
|
||||
let parser = XMLParser(contentsOf: url) else {
|
||||
return nil
|
||||
}
|
||||
let delegate = IBooksDisplayOptionsParserDelegate()
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else { return nil }
|
||||
switch delegate.orientationLock?.lowercased() {
|
||||
case "portrait-orientation", "portrait":
|
||||
return .portrait
|
||||
case "landscape-orientation", "landscape":
|
||||
return .landscape
|
||||
case "none":
|
||||
return .auto
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
resetPublicationState()
|
||||
|
||||
guard let parser = XMLParser(contentsOf: opfURL) else {
|
||||
throw RDEPUBParserError.invalidXML(opfURL)
|
||||
}
|
||||
|
||||
let delegate = OPFPackageParserDelegate()
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
|
||||
guard parser.parse() else {
|
||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL)
|
||||
}
|
||||
|
||||
let packageDocument = delegate.packageDocument()
|
||||
self.opfURL = opfURL
|
||||
self.metadata = packageDocument.metadata
|
||||
// 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。
|
||||
self.manifest = packageDocument.manifestByID
|
||||
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
|
||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
||||
tableOfContents
|
||||
}
|
||||
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Directory Size Extension
|
||||
|
||||
private extension URL {
|
||||
var directorySize: UInt64 {
|
||||
let fileManager = FileManager.default
|
||||
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
|
||||
return 0
|
||||
}
|
||||
var totalSize: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize else {
|
||||
continue
|
||||
}
|
||||
totalSize += UInt64(fileSize)
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
}
|
||||
@ -1,107 +0,0 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBPreferences: Equatable {
|
||||
|
||||
public var fontSize: CGFloat
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
|
||||
public var themeBackgroundColor: String?
|
||||
|
||||
public var themeTextColor: String?
|
||||
|
||||
public var fixedBackgroundColor: String?
|
||||
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
|
||||
public var numberOfColumns: Int
|
||||
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
reflowableContentInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil,
|
||||
fixedBackgroundColor: String? = nil,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
self.fixedBackgroundColor = fixedBackgroundColor
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
}
|
||||
|
||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
||||
RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: reflowableContentInsets,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
}
|
||||
|
||||
public func renderRequest(
|
||||
for page: EPUBPage,
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) -> RDEPUBRenderRequest? {
|
||||
if publication.layout == .fixed, let fixedSpread = page.fixedSpread {
|
||||
return .fixed(
|
||||
RDEPUBFixedRenderRequest(
|
||||
spread: fixedSpread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: fixedContentInset,
|
||||
backgroundColorCSS: fixedBackgroundColor,
|
||||
fit: fixedLayoutFit,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard publication.spine.indices.contains(page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .reflowable(
|
||||
RDEPUBReflowableRenderRequest(
|
||||
spineIndex: page.spineIndex,
|
||||
href: publication.spine[page.spineIndex].href,
|
||||
pageIndex: page.pageIndexInChapter,
|
||||
totalPagesInChapter: page.totalPagesInChapter,
|
||||
presentation: presentationStyle(viewportSize: viewportSize),
|
||||
targetLocation: targetLocation,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
||||
highlights: highlights,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBPublication {
|
||||
|
||||
public let parser: RDEPUBParser
|
||||
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
||||
}
|
||||
|
||||
public var metadata: RDEPUBMetadata {
|
||||
parser.metadata
|
||||
}
|
||||
|
||||
public var manifest: [String: RDEPUBManifestItem] {
|
||||
parser.manifest
|
||||
}
|
||||
|
||||
public var spine: [RDEPUBSpineItem] {
|
||||
parser.spine
|
||||
}
|
||||
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
parser.tableOfContents
|
||||
}
|
||||
|
||||
public var layout: RDEPUBLayout {
|
||||
metadata.layout
|
||||
}
|
||||
|
||||
public var readingProfile: RDEPUBReadingProfile {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
public var bookIdentifier: String? {
|
||||
metadata.identifier
|
||||
}
|
||||
|
||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
||||
guard layout == .fixed else {
|
||||
return false
|
||||
}
|
||||
guard metadata.spread?.lowercased() != "none" else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch preferences.fixedLayoutSpreadMode {
|
||||
case .never:
|
||||
return false
|
||||
case .always:
|
||||
return spine.count > 1
|
||||
case .automatic:
|
||||
return viewportSize.width > viewportSize.height
|
||||
}
|
||||
}
|
||||
|
||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
||||
EPUBFixedSpread.makeSpreads(
|
||||
spine: spine,
|
||||
spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,322 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBReadingSession {
|
||||
|
||||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
|
||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
|
||||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||||
|
||||
public init(publication: RDEPUBPublication) {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
public var resourceResolver: RDEPUBResourceResolver {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
public func resetRuntimeState() {
|
||||
activePages = []
|
||||
activeChapters = []
|
||||
clearPendingNavigation()
|
||||
currentViewport = nil
|
||||
currentReadingContext = nil
|
||||
}
|
||||
|
||||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
||||
activePages = snapshot.pages
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
pendingNavigationHighlightRangeInfo = nil
|
||||
}
|
||||
|
||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationLocation else {
|
||||
return nil
|
||||
}
|
||||
guard let spineIndex else {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationHighlightRangeInfo,
|
||||
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let pendingNavigationLocation else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
guard let spineIndex else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||||
}
|
||||
return page.spineIndex == spineIndex
|
||||
}
|
||||
|
||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: fixedSpread.primaryResource.href,
|
||||
progression: 0,
|
||||
lastProgression: 1
|
||||
)
|
||||
}
|
||||
|
||||
guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let progression: Double
|
||||
if page.totalPagesInChapter <= 1 {
|
||||
progression = 0
|
||||
} else {
|
||||
progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1)
|
||||
}
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: href,
|
||||
progression: progression,
|
||||
lastProgression: progression
|
||||
)
|
||||
}
|
||||
|
||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard currentPageNumber > 0,
|
||||
activePages.indices.contains(currentPageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||||
guard let location,
|
||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||||
let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else {
|
||||
return 0
|
||||
}
|
||||
return spineIndex
|
||||
}
|
||||
|
||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||||
return nil
|
||||
}
|
||||
guard let targetHref = resourceResolver.normalizedHref(location.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
return activePages.firstIndex(where: { page in
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) })
|
||||
}
|
||||
guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||||
return false
|
||||
}
|
||||
return self.resourceResolver.normalizedHref(href) == targetHref
|
||||
})
|
||||
}
|
||||
|
||||
guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex }
|
||||
guard !candidates.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if candidates.count == 1 {
|
||||
return candidates[0].offset
|
||||
}
|
||||
|
||||
let navigationProgression = location.navigationProgression
|
||||
let localIndex = min(
|
||||
candidates.count - 1,
|
||||
max(0, Int(round(navigationProgression * Double(candidates.count - 1))))
|
||||
)
|
||||
return candidates[localIndex].offset
|
||||
}
|
||||
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Int? {
|
||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: bookIdentifier
|
||||
), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||||
let shouldKeepPendingNavigation =
|
||||
normalizedLocation.fragment != nil ||
|
||||
normalizedLocation.rangeAnchor != nil ||
|
||||
hasTargetHighlightRangeInfo
|
||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
public func updateReadingContext(
|
||||
pageNumber: Int,
|
||||
location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int?,
|
||||
bookIdentifier: String?
|
||||
) {
|
||||
let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: bookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
|
||||
let resource = RDEPUBViewportResource(
|
||||
href: normalizedLocation.href,
|
||||
spineIndex: spineIndex,
|
||||
progression: normalizedLocation.progression,
|
||||
lastProgression: normalizedLocation.lastProgression,
|
||||
fragment: normalizedLocation.fragment
|
||||
)
|
||||
let viewport = RDEPUBViewport(
|
||||
resources: [resource],
|
||||
visiblePageNumber: pageNumber,
|
||||
chapterIndex: chapterIndex,
|
||||
isFixedLayout: publication.layout == .fixed
|
||||
)
|
||||
currentViewport = viewport
|
||||
currentReadingContext = RDEPUBReadingContext(
|
||||
location: normalizedLocation,
|
||||
viewport: viewport,
|
||||
pageNumber: pageNumber,
|
||||
chapterIndex: chapterIndex
|
||||
)
|
||||
|
||||
if pendingNavigationPageNum == pageNumber {
|
||||
clearPendingNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func makePaginationSnapshot(
|
||||
pageCounts: [Int],
|
||||
preferences: RDEPUBPreferences,
|
||||
layoutContext: RDEPUBNavigatorLayoutContext
|
||||
) -> PaginationSnapshot {
|
||||
if publication.layout == .fixed {
|
||||
let spreads = publication.makeFixedSpreads(
|
||||
preferences: preferences,
|
||||
viewportSize: layoutContext.viewportSize
|
||||
)
|
||||
|
||||
let chapters = spreads.enumerated().map { spreadIndex, spread in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: spread.primaryResource.spineIndex,
|
||||
title: spread.primaryResource.title,
|
||||
pageCount: 1
|
||||
)
|
||||
}
|
||||
|
||||
let pages = spreads.enumerated().map { spreadIndex, spread in
|
||||
EPUBPage(
|
||||
spineIndex: spread.primaryResource.spineIndex,
|
||||
chapterIndex: spreadIndex,
|
||||
pageIndexInChapter: 0,
|
||||
totalPagesInChapter: 1,
|
||||
chapterTitle: spread.primaryResource.title,
|
||||
fixedSpread: spread
|
||||
)
|
||||
}
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
var pages: [EPUBPage] = []
|
||||
var chapters: [EPUBChapterInfo] = []
|
||||
|
||||
for (spineIndex, pageCount) in pageCounts.enumerated() {
|
||||
guard pageCount > 0, spineIndex < publication.spine.count else { continue }
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let title = publication.spine[spineIndex].title
|
||||
|
||||
chapters.append(
|
||||
EPUBChapterInfo(
|
||||
spineIndex: spineIndex,
|
||||
title: title,
|
||||
pageCount: pageCount
|
||||
)
|
||||
)
|
||||
|
||||
for pageIndex in 0..<pageCount {
|
||||
pages.append(
|
||||
EPUBPage(
|
||||
spineIndex: spineIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: pageCount,
|
||||
chapterTitle: title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
||||
case auto
|
||||
case page
|
||||
case width
|
||||
}
|
||||
|
||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
||||
case automatic
|
||||
case always
|
||||
case never
|
||||
}
|
||||
|
||||
public struct RDEPUBPresentationStyle: Equatable {
|
||||
|
||||
public var viewportSize: CGSize
|
||||
|
||||
public var contentInsets: UIEdgeInsets
|
||||
|
||||
public var fontSize: CGFloat
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
|
||||
public var numberOfColumns: Int
|
||||
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public var themeBackgroundColor: String?
|
||||
|
||||
public var themeTextColor: String?
|
||||
|
||||
public init(
|
||||
viewportSize: CGSize,
|
||||
contentInsets: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) {
|
||||
self.viewportSize = viewportSize
|
||||
self.contentInsets = contentInsets
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var href: String
|
||||
|
||||
public var pageIndex: Int
|
||||
|
||||
public var totalPagesInChapter: Int
|
||||
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
|
||||
public var targetHighlightRangeInfo: String?
|
||||
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.pageIndex = pageIndex
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.presentation = presentation
|
||||
self.targetLocation = targetLocation
|
||||
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
||||
self.highlights = highlights
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
|
||||
public var spread: EPUBFixedSpread
|
||||
|
||||
public var viewportSize: CGSize
|
||||
|
||||
public var contentInset: UIEdgeInsets
|
||||
|
||||
public var backgroundColorCSS: String?
|
||||
|
||||
public var fit: RDEPUBFixedLayoutFit
|
||||
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColorCSS: String? = nil,
|
||||
fit: RDEPUBFixedLayoutFit = .page,
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
self.spread = spread
|
||||
self.viewportSize = viewportSize
|
||||
self.contentInset = contentInset
|
||||
self.backgroundColorCSS = backgroundColorCSS
|
||||
self.fit = fit
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBRenderRequest: Equatable {
|
||||
|
||||
case reflowable(RDEPUBReflowableRenderRequest)
|
||||
|
||||
case fixed(RDEPUBFixedRenderRequest)
|
||||
|
||||
public var isFixedLayout: Bool {
|
||||
if case .fixed = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public var primarySpineIndex: Int {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
return request.spineIndex
|
||||
case .fixed(let request):
|
||||
return request.spread.primaryResource.spineIndex
|
||||
}
|
||||
}
|
||||
|
||||
public var primaryHref: String {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
return request.href
|
||||
case .fixed(let request):
|
||||
return request.spread.primaryResource.href
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// 加密 EPUB 资源数据提供者。
|
||||
///
|
||||
/// 宿主 App 实现该协议以支持“解压目录内单文件加密”的 EPUB:
|
||||
/// SDK 读取章节 HTML、图片、内联 CSS 等资源时会优先向 provider 索取解密后的数据,
|
||||
/// provider 返回 nil 表示该文件无需解密,SDK 回退为直接读取磁盘明文
|
||||
/// (明文书、或加密书内的明文文件均走该兜底路径,因此对明文书零影响)。
|
||||
///
|
||||
/// 密钥推导与解密算法完全由宿主实现,SDK 不持有任何密钥。
|
||||
///
|
||||
/// 注意:方法会在排版 / 渲染的后台队列被同步调用,实现必须线程安全,
|
||||
/// 且除必要的文件 I/O 与解密计算外不得阻塞(例如不要在其中发起网络请求)。
|
||||
public protocol RDEPUBResourceDataProvider: AnyObject {
|
||||
|
||||
/// 返回指定文件解密后的数据。
|
||||
/// - Parameter fileURL: 解压目录内资源文件的绝对路径
|
||||
/// - Returns: 解密后的数据;返回 nil 表示该文件不需要解密,由 SDK 直读磁盘
|
||||
func resourceData(at fileURL: URL) -> Data?
|
||||
}
|
||||
|
||||
/// 加密资源访问登记表。
|
||||
///
|
||||
/// DTCoreText 在解析 `<img>` 时由自己内部加载图片文件,拿不到 parser 实例,
|
||||
/// 因此这里以弱引用登记所有启用了 provider 的 parser,
|
||||
/// 供 `RDEPUBDecryptingImageAttachment` 按文件路径反查所属书籍的 provider。
|
||||
enum RDEPUBResourceAccessRegistry {
|
||||
|
||||
private struct WeakParserBox {
|
||||
weak var parser: RDEPUBParser?
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
|
||||
private static var boxes: [WeakParserBox] = []
|
||||
|
||||
/// 登记启用了 provider 的 parser(重复登记会被去重,弱引用失效项顺带清理)
|
||||
static func register(_ parser: RDEPUBParser) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
boxes.removeAll { $0.parser == nil || $0.parser === parser }
|
||||
boxes.append(WeakParserBox(parser: parser))
|
||||
#if canImport(DTCoreText)
|
||||
RDEPUBDecryptingImageAttachment.registerTagClassIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 按文件路径反查 provider 并返回解密数据;
|
||||
/// 仅当文件位于某个已登记 parser 的解压目录内才会命中
|
||||
static func providedResourceData(at fileURL: URL) -> Data? {
|
||||
let targetPath = fileURL.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
lock.lock()
|
||||
let parsers = boxes.compactMap(\.parser)
|
||||
lock.unlock()
|
||||
for parser in parsers {
|
||||
guard let rootURL = parser.extractionRootURL else { continue }
|
||||
let rootPath = rootURL.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
guard targetPath.hasPrefix(rootPath + "/") else { continue }
|
||||
return parser.providedResourceData(at: fileURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBResourceResolver {
|
||||
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
}
|
||||
|
||||
public var opfDirectoryURL: URL? {
|
||||
parser.opfDirectoryURL
|
||||
}
|
||||
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.resourceURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
/// 是否配置了加密资源数据提供者
|
||||
public var hasResourceDataProvider: Bool {
|
||||
parser.resourceDataProvider != nil
|
||||
}
|
||||
|
||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
||||
parser.providedResourceData(at: fileURL)
|
||||
}
|
||||
|
||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
||||
public func resourceData(at fileURL: URL) -> Data? {
|
||||
parser.resourceData(at: fileURL)
|
||||
}
|
||||
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
}
|
||||
|
||||
let pathPart = href.components(separatedBy: "#").first ?? href
|
||||
if pathPart.isEmpty {
|
||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].href.components(separatedBy: "#").first
|
||||
}
|
||||
|
||||
let baseURL: URL
|
||||
if let spineIndex, parser.spine.indices.contains(spineIndex) {
|
||||
baseURL = opfDirectoryURL
|
||||
.appendingPathComponent(parser.spine[spineIndex].href)
|
||||
.deletingLastPathComponent()
|
||||
} else {
|
||||
baseURL = opfDirectoryURL
|
||||
}
|
||||
|
||||
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
|
||||
let resolvedPath = resolvedURL.path
|
||||
if resolvedPath.hasPrefix(opfPath) {
|
||||
return String(resolvedPath.dropFirst(opfPath.count))
|
||||
}
|
||||
return pathPart
|
||||
}
|
||||
|
||||
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
}
|
||||
|
||||
let pathPart = href.components(separatedBy: "#").first ?? href
|
||||
let basePath = baseHref.components(separatedBy: "#").first ?? baseHref
|
||||
let baseURL = opfDirectoryURL
|
||||
.appendingPathComponent(basePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
|
||||
if resolvedURL.path.hasPrefix(opfPath) {
|
||||
return String(resolvedURL.path.dropFirst(opfPath.count))
|
||||
}
|
||||
return pathPart
|
||||
}
|
||||
|
||||
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
|
||||
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
|
||||
return nil
|
||||
}
|
||||
return fileURL(forRelativePath: normalizedHref)
|
||||
}
|
||||
|
||||
public func normalizedLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
let rawHref = location.href
|
||||
let hrefParts = rawHref.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = hrefParts.first.map(String.init) ?? rawHref
|
||||
let fragment = location.fragment ?? (hrefParts.count > 1 ? String(hrefParts[1]) : nil)
|
||||
|
||||
let normalizedTargetHref: String
|
||||
if pathPart.isEmpty {
|
||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
normalizedTargetHref = parser.spine[spineIndex].href.components(separatedBy: "#").first ?? parser.spine[spineIndex].href
|
||||
} else {
|
||||
guard let href = normalizedHref(pathPart, relativeToSpineIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
normalizedTargetHref = href
|
||||
}
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: normalizedTargetHref,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: fragment,
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
||||
parser.spine.firstIndex { item in
|
||||
self.normalizedHref(item.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let normalizedHref = normalizedHref(location.href) else {
|
||||
return nil
|
||||
}
|
||||
return spineIndex(forNormalizedHref: normalizedHref)
|
||||
}
|
||||
|
||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].href
|
||||
}
|
||||
|
||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].title
|
||||
}
|
||||
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
||||
return parser.manifest.values.first {
|
||||
normalizedHref($0.href) == targetHref
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,349 +0,0 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
struct DebugMetrics {
|
||||
let streamedResponses: Int
|
||||
let inMemoryResponses: Int
|
||||
let failures: Int
|
||||
}
|
||||
|
||||
public static let scheme = "ss-reader"
|
||||
|
||||
public static let host = "book"
|
||||
|
||||
private weak var parser: RDEPUBParser?
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
|
||||
private let ioQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.io", qos: .utility)
|
||||
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
||||
private static var streamedResponseCount = 0
|
||||
private static var inMemoryResponseCount = 0
|
||||
private static var failureCount = 0
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func resetDebugMetrics() {
|
||||
debugMetricsQueue.sync {
|
||||
streamedResponseCount = 0
|
||||
inMemoryResponseCount = 0
|
||||
failureCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
static func debugMetricsSnapshot() -> DebugMetrics {
|
||||
debugMetricsQueue.sync {
|
||||
DebugMetrics(
|
||||
streamedResponses: streamedResponseCount,
|
||||
inMemoryResponses: inMemoryResponseCount,
|
||||
failures: failureCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
||||
syncQueue.sync {
|
||||
activeTasks[taskID] = true
|
||||
}
|
||||
|
||||
let requestURL = urlSchemeTask.request.url
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start")
|
||||
|
||||
guard let parser,
|
||||
let requestURL,
|
||||
let fileURL = parser.fileURL(forResourceURL: requestURL),
|
||||
fileManager.fileExists(atPath: fileURL.path) else {
|
||||
if let requestURL, Self.isOptionalResource(requestURL) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource")
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: requestURL.pathExtension),
|
||||
expectedContentLength: 0,
|
||||
textEncodingName: Self.textEncodingName(for: requestURL.pathExtension)
|
||||
)
|
||||
// H-08 fix: These calls happen synchronously in webView(_:start:) which
|
||||
// runs on the same queue WebKit calls start on, so no thread violation here.
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(Data())
|
||||
urlSchemeTask.didFinish()
|
||||
} else {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
clearTask(taskID)
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
let fileSize = resourceSize(for: fileURL)
|
||||
// 配置了加密资源 provider 时不能走流式分支:密文必须整体解密后再响应,
|
||||
// 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读)
|
||||
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
|
||||
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
||||
} else {
|
||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
||||
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
||||
}
|
||||
|
||||
private func clearTask(_ id: ObjectIdentifier) {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] == true
|
||||
}
|
||||
}
|
||||
|
||||
private func resourceSize(for url: URL) -> UInt64? {
|
||||
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? UInt64 else {
|
||||
return nil
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
|
||||
/// ensuring they run on the same serial queue that WebKit calls start() on.
|
||||
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
|
||||
/// of silently returning, satisfying the protocol requirement that every started
|
||||
/// task must receive a completion callback.
|
||||
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
// 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘
|
||||
let data: Data
|
||||
if let provided = self.parser?.providedResourceData(at: fileURL) {
|
||||
data = provided
|
||||
} else {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
}
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
self.dispatchTaskSuccessCallback(
|
||||
taskID: taskID,
|
||||
urlSchemeTask: urlSchemeTask
|
||||
) {
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
Self.recordInMemoryResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
return
|
||||
} catch {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
Self.recordFailure()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
}
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
}
|
||||
}
|
||||
|
||||
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: Int(fileSize),
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
||||
urlSchemeTask.didReceive(response)
|
||||
}
|
||||
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
Self.recordFailure()
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = 65_536
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
}
|
||||
while true {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
// H-08 fix: Task was cancelled. Send didFailWithError on main queue.
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
return
|
||||
}
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
}
|
||||
self.dispatchTaskSuccessCallback(
|
||||
taskID: taskID,
|
||||
urlSchemeTask: urlSchemeTask
|
||||
) {
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchTaskCallbackIfActive(taskID: ObjectIdentifier, _ callback: @escaping () -> Void) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.isTaskActive(taskID) else { return }
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchTaskSuccessCallback(
|
||||
taskID: ObjectIdentifier,
|
||||
urlSchemeTask: any WKURLSchemeTask,
|
||||
_ callback: @escaping () -> Void
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
return
|
||||
}
|
||||
guard self.isTaskActive(taskID) else {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
self.clearTask(taskID)
|
||||
return
|
||||
}
|
||||
callback()
|
||||
self.clearTask(taskID)
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordStreamedResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
streamedResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordInMemoryResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
inMemoryResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
debugMetricsQueue.sync {
|
||||
failureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm":
|
||||
return "text/html"
|
||||
case "xhtml":
|
||||
return "application/xhtml+xml"
|
||||
case "css":
|
||||
return "text/css"
|
||||
case "js":
|
||||
return "application/javascript"
|
||||
case "xml", "opf", "ncx":
|
||||
return "application/xml"
|
||||
case "svg":
|
||||
return "image/svg+xml"
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "ttf":
|
||||
return "font/ttf"
|
||||
case "otf":
|
||||
return "font/otf"
|
||||
case "woff":
|
||||
return "font/woff"
|
||||
case "woff2":
|
||||
return "font/woff2"
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "mp4":
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
||||
return "utf-8"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isOptionalResource(_ url: URL) -> Bool {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
|
||||
/// Reads the real safe area from the key window so correct values are
|
||||
/// available even before a view has been laid out in the hierarchy.
|
||||
/// Must be called on the main thread.
|
||||
public enum RDEPUBSafeArea {
|
||||
|
||||
/// Minimum text margins applied when the device safe area on an edge is
|
||||
/// smaller (e.g. no notch / no home indicator). These are aesthetic
|
||||
/// paddings, not approximations of the safe area itself.
|
||||
public static let minimumVerticalTextMargin: CGFloat = 20
|
||||
|
||||
public static let minimumHorizontalTextMargin: CGFloat = 16
|
||||
|
||||
public static func keyWindow() -> UIWindow? {
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
if let window = scenes
|
||||
.filter({ $0.activationState == .foregroundActive })
|
||||
.flatMap({ $0.windows })
|
||||
.first(where: { $0.isKeyWindow }) {
|
||||
return window
|
||||
}
|
||||
if let window = scenes
|
||||
.flatMap({ $0.windows })
|
||||
.first(where: { $0.isKeyWindow }) {
|
||||
return window
|
||||
}
|
||||
return UIApplication.shared.delegate?.window ?? nil
|
||||
}
|
||||
|
||||
public static func insets() -> UIEdgeInsets {
|
||||
if let window = keyWindow() {
|
||||
return window.safeAreaInsets
|
||||
}
|
||||
// No key window yet (early launch): create a detached window to read
|
||||
// the device safe area, same fallback as GKNavigationBarSwift.
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
if window.safeAreaInsets.bottom <= 0 {
|
||||
window.rootViewController = UIViewController()
|
||||
}
|
||||
return window.safeAreaInsets
|
||||
}
|
||||
|
||||
/// Prefers insets measured from a view already installed in the hierarchy;
|
||||
/// falls back to the key-window insets when the view is not laid out yet
|
||||
/// and reports .zero.
|
||||
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
|
||||
if let measuredInsets, measuredInsets != .zero {
|
||||
return measuredInsets
|
||||
}
|
||||
return insets()
|
||||
}
|
||||
|
||||
/// Default reflowable content insets derived from the live device safe
|
||||
/// area instead of hard-coded heights.
|
||||
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
|
||||
let safe = insets()
|
||||
return UIEdgeInsets(
|
||||
top: max(safe.top, minimumVerticalTextMargin),
|
||||
left: max(safe.left, minimumHorizontalTextMargin),
|
||||
bottom: max(safe.bottom, minimumVerticalTextMargin),
|
||||
right: max(safe.right, minimumHorizontalTextMargin)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBSearchEngine {
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
self.parser = parser
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var searchMatches: [RDEPUBSearchMatch] = []
|
||||
for item in publication.spine where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let html = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent())
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href
|
||||
searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword))
|
||||
}
|
||||
|
||||
return searchMatches
|
||||
}
|
||||
|
||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let baseURL {
|
||||
options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL
|
||||
}
|
||||
|
||||
if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed.string
|
||||
}
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
return stripped
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
|
||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let source = text as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [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)
|
||||
results.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: href,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
|
||||
public var href: String
|
||||
|
||||
public var progression: Double
|
||||
|
||||
public var previewText: String
|
||||
|
||||
public var localMatchIndex: Int
|
||||
|
||||
public var rangeLocation: Int?
|
||||
|
||||
public var rangeLength: Int
|
||||
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public var cfi: String?
|
||||
|
||||
public var rangeCFI: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
progression: Double,
|
||||
previewText: String,
|
||||
localMatchIndex: Int,
|
||||
rangeLocation: Int? = nil,
|
||||
rangeLength: Int,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
||||
cfi: String? = nil,
|
||||
rangeCFI: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.progression = progression
|
||||
self.previewText = previewText
|
||||
self.localMatchIndex = localMatchIndex
|
||||
self.rangeLocation = rangeLocation
|
||||
self.rangeLength = rangeLength
|
||||
self.rangeAnchor = rangeAnchor
|
||||
self.cfi = cfi
|
||||
self.rangeCFI = rangeCFI
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
|
||||
public var keyword: String
|
||||
|
||||
public var totalMatchCount: Int
|
||||
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public var currentMatch: RDEPUBSearchMatch?
|
||||
|
||||
public init(
|
||||
keyword: String,
|
||||
totalMatchCount: Int,
|
||||
currentMatchIndex: Int?,
|
||||
currentMatch: RDEPUBSearchMatch?
|
||||
) {
|
||||
self.keyword = keyword
|
||||
self.totalMatchCount = totalMatchCount
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
self.currentMatch = currentMatch
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchState: Codable, Equatable {
|
||||
|
||||
public var keyword: String
|
||||
|
||||
public var matches: [RDEPUBSearchMatch]
|
||||
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
||||
self.keyword = keyword
|
||||
self.matches = matches
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
}
|
||||
|
||||
public var currentMatch: RDEPUBSearchMatch? {
|
||||
guard let currentMatchIndex,
|
||||
matches.indices.contains(currentMatchIndex) else {
|
||||
return nil
|
||||
}
|
||||
return matches[currentMatchIndex]
|
||||
}
|
||||
|
||||
public var result: RDEPUBSearchResult {
|
||||
RDEPUBSearchResult(
|
||||
keyword: keyword,
|
||||
totalMatchCount: matches.count,
|
||||
currentMatchIndex: currentMatchIndex.map { $0 + 1 },
|
||||
currentMatch: currentMatch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
|
||||
public var href: String
|
||||
|
||||
public var matchCount: Int
|
||||
|
||||
public var activeLocalMatchIndex: Int?
|
||||
|
||||
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
|
||||
self.href = href
|
||||
self.matchCount = matchCount
|
||||
self.activeLocalMatchIndex = activeLocalMatchIndex
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchPresentation: Codable, Equatable {
|
||||
|
||||
public var keyword: String
|
||||
|
||||
public var resources: [RDEPUBSearchPresentationResource]
|
||||
|
||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
||||
self.keyword = keyword
|
||||
self.resources = resources
|
||||
}
|
||||
}
|
||||
@ -1,215 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBStyleSheetBuilder {
|
||||
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
presentation: RDEPUBPresentationStyle
|
||||
) -> String {
|
||||
let styleTag = "<style id=\"ss-reader-pagination\">\(measurementCSS(for: presentation))</style>"
|
||||
if html.range(of: "</head>", options: .caseInsensitive) != nil {
|
||||
return html.replacingOccurrences(of: "</head>", with: styleTag + "</head>", options: .caseInsensitive)
|
||||
}
|
||||
if html.range(of: "<body", options: .caseInsensitive) != nil {
|
||||
return html.replacingOccurrences(of: "<body", with: styleTag + "<body", options: .caseInsensitive)
|
||||
}
|
||||
return styleTag + html
|
||||
}
|
||||
|
||||
public static func renderCSS(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-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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -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
|
||||
};
|
||||
})();
|
||||
@ -1,266 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: {{BACKGROUND}}; }
|
||||
body { position: relative; }
|
||||
#ss-fixed-root {
|
||||
position: absolute;
|
||||
top: {{INSET_TOP}}px;
|
||||
right: {{INSET_RIGHT}}px;
|
||||
bottom: {{INSET_BOTTOM}}px;
|
||||
left: {{INSET_LEFT}}px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ss-fixed-viewport {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ss-fixed-page {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.ss-fixed-page[data-page-type="single"],
|
||||
.ss-fixed-page[data-page-type="center"] {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.ss-fixed-page[data-page-type="left"] {
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.ss-fixed-page[data-page-type="right"] {
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
transform-origin: top left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ss-fixed-root">{{PANES}}</div>
|
||||
<script>
|
||||
(function() {
|
||||
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
|
||||
var safeAreaInsets = { top: {{INSET_TOP}}, right: {{INSET_RIGHT}}, bottom: {{INSET_BOTTOM}}, left: {{INSET_LEFT}} };
|
||||
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
|
||||
var fit = '{{FIT_MODE}}';
|
||||
var pendingFrames = 0;
|
||||
var readySent = false;
|
||||
|
||||
function notifyReady() {
|
||||
if (readySent) { return; }
|
||||
readySent = true;
|
||||
try {
|
||||
window.webkit.messageHandlers.{{FIXED_LAYOUT_READY_MESSAGE}}.postMessage({ loaded: true });
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSizeFromViewportMetaTag(iframe) {
|
||||
try {
|
||||
var viewport = iframe.contentWindow.document.querySelector('meta[name="viewport"]');
|
||||
if (!viewport) { return null; }
|
||||
var regex = /(\w+) *= *([^\s,]+)/g;
|
||||
var properties = {};
|
||||
var match;
|
||||
while ((match = regex.exec(viewport.content))) {
|
||||
properties[match[1]] = match[2];
|
||||
}
|
||||
var width = Number.parseFloat(properties.width);
|
||||
var height = Number.parseFloat(properties.height);
|
||||
if (!width || !height) { return null; }
|
||||
return { width: width, height: height };
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCssPixelValue(value) {
|
||||
if (!value) { return 0; }
|
||||
var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/);
|
||||
return match ? Number.parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
function sizeFromElement(element) {
|
||||
if (!element) { return null; }
|
||||
var width = parseCssPixelValue(element.style && element.style.width);
|
||||
var height = parseCssPixelValue(element.style && element.style.height);
|
||||
if (!width || !height) {
|
||||
width = parseCssPixelValue(element.getAttribute && element.getAttribute('width'));
|
||||
height = parseCssPixelValue(element.getAttribute && element.getAttribute('height'));
|
||||
}
|
||||
if (!width || !height) {
|
||||
var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
|
||||
if (rect) {
|
||||
width = width || rect.width;
|
||||
height = height || rect.height;
|
||||
}
|
||||
}
|
||||
if (width > 1 && height > 1) {
|
||||
return { width: width, height: height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePageSizeFromDocumentBox(iframe) {
|
||||
try {
|
||||
var doc = iframe.contentWindow.document;
|
||||
return sizeFromElement(doc.body) ||
|
||||
sizeFromElement(doc.documentElement) ||
|
||||
sizeFromElement(doc.querySelector('[id$="_hype_container"]')) ||
|
||||
sizeFromElement(doc.querySelector('iframe')) ||
|
||||
null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSizeFromNestedFrame(iframe) {
|
||||
try {
|
||||
var nestedFrame = iframe.contentWindow.document.querySelector('iframe');
|
||||
if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) {
|
||||
return null;
|
||||
}
|
||||
return parsePageSizeFromViewportMetaTag(nestedFrame) ||
|
||||
parsePageSizeFromDocumentBox(nestedFrame) ||
|
||||
parsePageSizeFromEmbeddedImage(nestedFrame);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSizeFromEmbeddedImage(iframe) {
|
||||
try {
|
||||
var img = iframe.contentWindow.document.querySelector('img');
|
||||
if (!img || !img.naturalWidth || !img.naturalHeight) { return null; }
|
||||
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pageViewportSize(iframe) {
|
||||
var viewport = iframe.closest('.ss-fixed-viewport');
|
||||
if (!viewport) { return viewportSize; }
|
||||
var width = Math.max(1, viewport.clientWidth);
|
||||
var height = Math.max(1, viewport.clientHeight);
|
||||
if (width <= 1 || height <= 1) {
|
||||
return viewportSize;
|
||||
}
|
||||
return {
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
function usesWidthFit(pageSize, localViewport) {
|
||||
if (fit === Fit.WIDTH) {
|
||||
return true;
|
||||
}
|
||||
if (fit !== Fit.AUTO) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var widthRatio = localViewport.width / pageSize.width;
|
||||
var heightRatio = localViewport.height / pageSize.height;
|
||||
return widthRatio <= heightRatio;
|
||||
}
|
||||
|
||||
function layoutPage(iframe) {
|
||||
var pageSize = iframe.__ssPageSize;
|
||||
if (!pageSize) { return; }
|
||||
|
||||
var pageType = iframe.dataset.pageType || 'single';
|
||||
var localViewport = pageViewportSize(iframe);
|
||||
iframe.style.width = pageSize.width + 'px';
|
||||
iframe.style.height = pageSize.height + 'px';
|
||||
|
||||
var widthRatio = localViewport.width / pageSize.width;
|
||||
var heightRatio = localViewport.height / pageSize.height;
|
||||
var widthFit = usesWidthFit(pageSize, localViewport);
|
||||
var scale = widthFit ? widthRatio : Math.min(widthRatio, heightRatio);
|
||||
var scaledWidth = pageSize.width * scale;
|
||||
var scaledHeight = pageSize.height * scale;
|
||||
|
||||
var offsetX, offsetY;
|
||||
if (pageType === 'left') {
|
||||
offsetX = localViewport.width - scaledWidth;
|
||||
} else if (pageType === 'right') {
|
||||
offsetX = 0;
|
||||
} else {
|
||||
offsetX = (localViewport.width - scaledWidth) / 2;
|
||||
}
|
||||
|
||||
if (widthFit && scaledHeight > localViewport.height) {
|
||||
offsetY = safeAreaInsets.top;
|
||||
} else {
|
||||
offsetY = (localViewport.height - scaledHeight) / 2;
|
||||
offsetY += (safeAreaInsets.top - safeAreaInsets.bottom) / 2;
|
||||
}
|
||||
|
||||
iframe.style.left = offsetX + 'px';
|
||||
iframe.style.top = offsetY + 'px';
|
||||
iframe.style.transform = 'scale(' + scale + ')';
|
||||
}
|
||||
|
||||
function preparePage(iframe) {
|
||||
pendingFrames += 1;
|
||||
|
||||
function finalizeLoad() {
|
||||
pendingFrames = Math.max(0, pendingFrames - 1);
|
||||
if (pendingFrames === 0) {
|
||||
notifyReady();
|
||||
}
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
iframe.__ssPageSize =
|
||||
parsePageSizeFromViewportMetaTag(iframe) ||
|
||||
parsePageSizeFromDocumentBox(iframe) ||
|
||||
parsePageSizeFromNestedFrame(iframe) ||
|
||||
parsePageSizeFromEmbeddedImage(iframe) ||
|
||||
pageViewportSize(iframe);
|
||||
layoutPage(iframe);
|
||||
window.setTimeout(function() {
|
||||
layoutPage(iframe);
|
||||
}, 250);
|
||||
finalizeLoad();
|
||||
}
|
||||
|
||||
iframe.addEventListener('load', function() {
|
||||
window.setTimeout(onLoad, 100);
|
||||
});
|
||||
|
||||
iframe.addEventListener('error', function() {
|
||||
iframe.__ssPageSize = pageViewportSize(iframe);
|
||||
layoutPage(iframe);
|
||||
finalizeLoad();
|
||||
});
|
||||
}
|
||||
|
||||
var pages = document.querySelectorAll('.ss-fixed-page');
|
||||
Array.prototype.forEach.call(pages, preparePage);
|
||||
if (pages.length === 0) {
|
||||
notifyReady();
|
||||
}
|
||||
window.addEventListener('resize', function() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.ss-fixed-page'), layoutPage);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,13 +0,0 @@
|
||||
(function() {
|
||||
if (window.rangy) { return; }
|
||||
|
||||
window.rangy = {
|
||||
initialized: true,
|
||||
init: function() {
|
||||
return window.rangy;
|
||||
},
|
||||
createRange: function(doc) {
|
||||
return (doc || document).createRange();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -1,93 +0,0 @@
|
||||
(function() {
|
||||
if (!window.rangy) {
|
||||
window.rangy = {};
|
||||
}
|
||||
if (window.rangy.serializeRange && window.rangy.deserializeRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 nodePath(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
while (current && current !== current.ownerDocument) {
|
||||
var parent = current.parentNode;
|
||||
if (!parent) { break; }
|
||||
var index = Array.prototype.indexOf.call(parent.childNodes, current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function nodeFromPath(doc, path) {
|
||||
var current = doc;
|
||||
for (var index = 0; index < path.length; index += 1) {
|
||||
if (!current || !current.childNodes || current.childNodes.length <= path[index]) {
|
||||
return null;
|
||||
}
|
||||
current = current.childNodes[path[index]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function documentContexts(rootDoc) {
|
||||
var items = [];
|
||||
if (!rootDoc) { return items; }
|
||||
items.push(rootDoc);
|
||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||
Array.prototype.forEach.call(frames, function(frame) {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
documentContexts(frame.contentDocument).forEach(function(item) {
|
||||
items.push(item);
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
window.rangy.serializeRange = function(range, doc) {
|
||||
var ownerDocument = doc || (range && range.startContainer && range.startContainer.ownerDocument) || document;
|
||||
return JSON.stringify({
|
||||
kind: 'dom-range',
|
||||
documentHref: normalizeHref(ownerDocument.location && ownerDocument.location.href),
|
||||
startPath: nodePath(range.startContainer),
|
||||
startOffset: range.startOffset,
|
||||
endPath: nodePath(range.endContainer),
|
||||
endOffset: range.endOffset
|
||||
});
|
||||
};
|
||||
|
||||
window.rangy.deserializeRange = function(rangeInfo, rootDoc) {
|
||||
try {
|
||||
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
|
||||
var documentHref = normalizeHref(payload.documentHref);
|
||||
var resolvedDocument = (documentContexts(rootDoc || document).find(function(candidate) {
|
||||
return normalizeHref(candidate.location && candidate.location.href) === documentHref;
|
||||
})) || (rootDoc || document);
|
||||
var startNode = nodeFromPath(resolvedDocument, payload.startPath || []);
|
||||
var endNode = nodeFromPath(resolvedDocument, payload.endPath || []);
|
||||
if (!startNode || !endNode) { return null; }
|
||||
var range = resolvedDocument.createRange();
|
||||
range.setStart(startNode, payload.startOffset || 0);
|
||||
range.setEnd(endNode, payload.endOffset || 0);
|
||||
return range;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -1,56 +0,0 @@
|
||||
/* 适配搜狗百科 bar 里的 icon 被染色 */
|
||||
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
|
||||
.abstract-img-none {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* 适配百度搜索图片丢失 */
|
||||
.c-touchable-feedback-no-default * {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
img, video {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/*背景纯黑*/
|
||||
*, *:before, *:after {
|
||||
background-color: rgba(0, 0, 0, 1) !important;
|
||||
}
|
||||
|
||||
.wr-business-container,
|
||||
.wr-business-container *,
|
||||
.wr-business-container *:before,
|
||||
.wr-business-container *:after {
|
||||
background-color: rgb(28, 28, 29) !important;
|
||||
}
|
||||
|
||||
/*背景颜色和一般字体颜色*/
|
||||
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
|
||||
color: rgba(180, 180, 182, 1) !important;
|
||||
border-color: #555555 !important;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
|
||||
/*超链接*/
|
||||
a {
|
||||
color: rgba(84, 127, 176, 1) !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color : rgb(218, 220, 224);
|
||||
background-color : rgb(10, 14, 18) !important;
|
||||
border-left: 1px solid rgba(116, 120, 124, 1);
|
||||
}
|
||||
|
||||
img {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
ul li:before, ol li:before {
|
||||
background-color: rgba(196, 200, 204, 1) !important;
|
||||
}
|
||||
@ -1,221 +0,0 @@
|
||||
/* this file is processed with xxd via a build rule and embedded in library */
|
||||
|
||||
/* these styles come from Safari */
|
||||
|
||||
/* note that comments are only permitted before selectors and before the styles */
|
||||
|
||||
/* DO NOT fiddle with this file if you want to have your own styles,
|
||||
pass your own stylesheet via the option parameter to override these defaults */
|
||||
|
||||
head {
|
||||
display:none;
|
||||
}
|
||||
|
||||
title {
|
||||
display:none;
|
||||
}
|
||||
|
||||
style {
|
||||
display:none;
|
||||
}
|
||||
|
||||
link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
script {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
display:block;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
body {
|
||||
display:block;
|
||||
font-size:16px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
article,aside,footer,header,hgroup,nav,section {
|
||||
display:block;
|
||||
}
|
||||
|
||||
p {
|
||||
display:block;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
img {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
ul,ol,menu,dir {
|
||||
display:block;
|
||||
margin:1em 0 1em 0;
|
||||
padding-left:24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type:disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type:decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
display:list-item;
|
||||
}
|
||||
|
||||
ul ul, ol ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:Courier;
|
||||
}
|
||||
|
||||
pre, xmp, plaintext, listing {
|
||||
display: block;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color:#2262A3;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color:#2262A3;
|
||||
}
|
||||
|
||||
center {
|
||||
text-align:center;
|
||||
display:block;
|
||||
}
|
||||
|
||||
strong,b {
|
||||
font-weight:bold;
|
||||
}
|
||||
|
||||
i,em {
|
||||
font-style:italic;
|
||||
}
|
||||
|
||||
u {
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
big {
|
||||
font-size:bigger;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size:smaller;
|
||||
}
|
||||
|
||||
sub {
|
||||
font-size:smaller;
|
||||
vertical-align:sub;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-size:smaller;
|
||||
vertical-align:super;
|
||||
}
|
||||
|
||||
s,strike,del {
|
||||
text-decoration:line-through;
|
||||
}
|
||||
|
||||
tt,code,kbd,samp {
|
||||
font-family:monospace;
|
||||
}
|
||||
|
||||
pre,xmp,plaintext,listing {
|
||||
display:block;
|
||||
font-family:monospace;
|
||||
white-space:pre;
|
||||
margin-top:1em;
|
||||
margin-right:0;
|
||||
margin-bottom:1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color:rgba(0,0,0,.05);
|
||||
padding-top:1em;
|
||||
padding-bottom:1em;
|
||||
border-radius:0.3em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display:block;
|
||||
font-size:1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display:block;
|
||||
font-size:1.4em;
|
||||
margin-top: 0.83em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display:block;
|
||||
font-size:1.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display:block;
|
||||
font-size:1.2em;
|
||||
margin-top: 1.33em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
display:block;
|
||||
font-size:1.1em;
|
||||
margin-top: 1.67em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
display:block;
|
||||
font-size:1em;
|
||||
margin-top: 2.33em;
|
||||
}
|
||||
|
||||
div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
margin:0.5em auto 0.5em auto;
|
||||
border-style: inset;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
table {
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
display: block;
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
/* <pre>代码块,注意必须写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;
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
/* <pre>代码块,注意必须写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;
|
||||
}
|
||||
@ -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<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<T: Equatable>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 "<empty>" }
|
||||
if collapsed.count <= limit {
|
||||
return collapsed
|
||||
}
|
||||
let head = collapsed.prefix(limit)
|
||||
return "\(head)…"
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user