feat: 支持加密 EPUB、试读墙、工具栏定制与朝向锁定
- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/ 图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书 禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件; RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。 - 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/ DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。 - 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入, 内置栏已 conform,configureTopToolView 改协议类型。 - 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主, iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、 打开后主动转向。 注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前 即存在的工作区修改,一并纳入。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
83dfa40299
commit
d5a7755702
3082
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3082
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -18,9 +18,9 @@
|
|||||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
||||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
||||||
|
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
||||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
|
||||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
||||||
@ -54,6 +54,7 @@
|
|||||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
||||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@ -65,7 +66,6 @@
|
|||||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
|
||||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|||||||
@ -59,13 +59,22 @@ public final class RDEPUBNoteResolver {
|
|||||||
guard let fragment,
|
guard let fragment,
|
||||||
!fragment.isEmpty,
|
!fragment.isEmpty,
|
||||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||||||
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
|
let html = chapterHTML(at: fileURL) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return elementHTML(withID: fragment, in: html)
|
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? {
|
private func elementHTML(withID id: String, in html: String) -> String? {
|
||||||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
||||||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
||||||
|
|||||||
@ -24,6 +24,13 @@ public enum RDEPUBPageSpread: String, Codable {
|
|||||||
case center
|
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 struct RDEPUBMetadata: Codable, Equatable {
|
||||||
|
|
||||||
public var identifier: String?
|
public var identifier: String?
|
||||||
@ -42,6 +49,10 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
|||||||
|
|
||||||
public var readingProgression: RDEPUBReadingProgression
|
public var readingProgression: RDEPUBReadingProgression
|
||||||
|
|
||||||
|
/// 渲染朝向;nil = 未声明(不锁定,跟随系统)。
|
||||||
|
/// 来源优先级:OPF rendition:orientation > META-INF/com.apple.ibooks.display-options.xml。
|
||||||
|
public var orientation: RDEPUBOrientation?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
identifier: String? = nil,
|
identifier: String? = nil,
|
||||||
title: String = "",
|
title: String = "",
|
||||||
@ -50,7 +61,8 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
|||||||
version: String? = nil,
|
version: String? = nil,
|
||||||
layout: RDEPUBLayout = .reflowable,
|
layout: RDEPUBLayout = .reflowable,
|
||||||
spread: String? = nil,
|
spread: String? = nil,
|
||||||
readingProgression: RDEPUBReadingProgression = .auto
|
readingProgression: RDEPUBReadingProgression = .auto,
|
||||||
|
orientation: RDEPUBOrientation? = nil
|
||||||
) {
|
) {
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -60,6 +72,7 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
|||||||
self.layout = layout
|
self.layout = layout
|
||||||
self.spread = spread
|
self.spread = spread
|
||||||
self.readingProgression = readingProgression
|
self.readingProgression = readingProgression
|
||||||
|
self.orientation = orientation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -151,3 +151,48 @@ private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
|||||||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -180,6 +180,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
|||||||
property == "rendition:spread" {
|
property == "rendition:spread" {
|
||||||
metadata.spread = attributeDict["content"]
|
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(),
|
if let property = currentMetaProperty?.lowercased(),
|
||||||
property == "title",
|
property == "title",
|
||||||
let refinesID = currentMetaRefinesID,
|
let refinesID = currentMetaRefinesID,
|
||||||
@ -270,6 +275,8 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
|||||||
metadata.layout = layout(from: normalizedValue)
|
metadata.layout = layout(from: normalizedValue)
|
||||||
case "rendition:spread":
|
case "rendition:spread":
|
||||||
metadata.spread = normalizedValue
|
metadata.spread = normalizedValue
|
||||||
|
case "rendition:orientation":
|
||||||
|
metadata.orientation = orientation(from: normalizedValue)
|
||||||
case "dcterms:identifier", "identifier":
|
case "dcterms:identifier", "identifier":
|
||||||
if metadata.identifier == nil {
|
if metadata.identifier == nil {
|
||||||
metadata.identifier = normalizedValue
|
metadata.identifier = normalizedValue
|
||||||
@ -315,6 +322,19 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
|||||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
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 {
|
enum XMLSection {
|
||||||
|
|||||||
@ -62,7 +62,8 @@ extension RDEPUBParser {
|
|||||||
}
|
}
|
||||||
for item in coverCandidates {
|
for item in coverCandidates {
|
||||||
if let fileURL = fileURL(forRelativePath: item.href),
|
if let fileURL = fileURL(forRelativePath: item.href),
|
||||||
let image = UIImage(contentsOfFile: fileURL.path) {
|
let data = resourceData(at: fileURL),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,6 +81,10 @@ extension RDEPUBParser {
|
|||||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if let provided = providedResourceData(at: fileURL) {
|
||||||
|
return String(data: provided, encoding: .utf8)
|
||||||
|
?? String(data: provided, encoding: .utf16)
|
||||||
|
}
|
||||||
return try? String(contentsOf: fileURL)
|
return try? String(contentsOf: fileURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,28 @@ public final class RDEPUBParser {
|
|||||||
opfURL?.deletingLastPathComponent()
|
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() {}
|
public init() {}
|
||||||
|
|
||||||
// MARK: - Cache Management
|
// MARK: - Cache Management
|
||||||
@ -101,6 +123,37 @@ public final class RDEPUBParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try parseOPF(at: packageURL)
|
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 {
|
public func parseOPF(at opfURL: URL) throws {
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,21 @@ public final class RDEPUBResourceResolver {
|
|||||||
parser.fileURL(forResourceURL: resourceURL)
|
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? {
|
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||||
guard let opfDirectoryURL else {
|
guard let opfDirectoryURL else {
|
||||||
return href.components(separatedBy: "#").first
|
return href.components(separatedBy: "#").first
|
||||||
|
|||||||
@ -87,7 +87,9 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||||
|
|
||||||
let fileSize = resourceSize(for: fileURL)
|
let fileSize = resourceSize(for: fileURL)
|
||||||
if let fileSize, fileSize > 524_288 {
|
// 配置了加密资源 provider 时不能走流式分支:密文必须整体解密后再响应,
|
||||||
|
// 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读)
|
||||||
|
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
|
||||||
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
||||||
} else {
|
} else {
|
||||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||||
@ -133,7 +135,13 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: fileURL)
|
// 优先向加密资源 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 {
|
guard self.isTaskActive(taskID) else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
|
||||||
|
/// 支持加密图片的 DTCoreText 图片附件。
|
||||||
|
///
|
||||||
|
/// DTCoreText 解析 `<img>` 时会直接读磁盘文件来取图并推算尺寸;
|
||||||
|
/// 对加密图片该读取会解码失败,表现为 `image == nil` 且 `contentURL` 被保留。
|
||||||
|
/// 本子类在 super 初始化完成后接管这种失败场景:
|
||||||
|
/// 经 `RDEPUBResourceAccessRegistry` 反查 provider 解密数据并调用 `setImage`
|
||||||
|
/// (setter 会自动补齐 originalSize / displaySize,后续 willFlush 阶段的
|
||||||
|
/// 附件规范化与绘制均按明文图片的既有路径工作)。
|
||||||
|
///
|
||||||
|
/// 通过 `DTTextAttachment.registerClass(_:forTagName:)` 全局注册,
|
||||||
|
/// 未启用 provider 的书行为与父类完全一致。
|
||||||
|
final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment {
|
||||||
|
|
||||||
|
private static var didRegisterTagClass = false
|
||||||
|
|
||||||
|
private static let registerLock = NSLock()
|
||||||
|
|
||||||
|
/// 将本类注册为 `<img>` 标签的附件类(进程内一次性,线程安全)
|
||||||
|
static func registerTagClassIfNeeded() {
|
||||||
|
registerLock.lock()
|
||||||
|
defer { registerLock.unlock() }
|
||||||
|
guard !didRegisterTagClass else { return }
|
||||||
|
DTTextAttachment.registerClass(RDEPUBDecryptingImageAttachment.self, forTagName: "img")
|
||||||
|
didRegisterTagClass = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override init!(element: DTHTMLElement!, options: [AnyHashable: Any]! = [:]) {
|
||||||
|
super.init(element: element, options: options)
|
||||||
|
decryptImageIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decryptImageIfNeeded() {
|
||||||
|
// super 已成功取到图(明文图片 / data: 内联图)时不干预
|
||||||
|
guard image == nil,
|
||||||
|
let contentURL,
|
||||||
|
contentURL.isFileURL,
|
||||||
|
let data = RDEPUBResourceAccessRegistry.providedResourceData(at: contentURL.standardizedFileURL),
|
||||||
|
let decrypted = UIImage(data: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
image = decrypted
|
||||||
|
// 对齐 DTCoreText 本地图片加载成功的行为:置空 contentURL,
|
||||||
|
// 避免下游误当作待加载的远程 / 磁盘图片再次直读密文
|
||||||
|
self.contentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@ -89,7 +89,7 @@ struct RDEPUBRenderDiagnosticsCollector {
|
|||||||
diagnostics.append(resolution.diagnostic)
|
diagnostics.append(resolution.diagnostic)
|
||||||
|
|
||||||
if let fileURL = resolution.resolvedFileURL,
|
if let fileURL = resolution.resolvedFileURL,
|
||||||
let css = try? String(contentsOf: fileURL),
|
let css = styleSheetContent(at: fileURL, resourceResolver: resourceResolver),
|
||||||
resolution.diagnostic.existsOnDisk {
|
resolution.diagnostic.existsOnDisk {
|
||||||
let cssWithResolvedURLs = rewriteCSSResourceURLs(
|
let cssWithResolvedURLs = rewriteCSSResourceURLs(
|
||||||
in: css,
|
in: css,
|
||||||
@ -106,9 +106,19 @@ struct RDEPUBRenderDiagnosticsCollector {
|
|||||||
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
|
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取样式表内容:优先经加密资源 provider 解密,明文文件保持原直读路径
|
||||||
|
private static func styleSheetContent(
|
||||||
|
at fileURL: URL,
|
||||||
|
resourceResolver: RDEPUBResourceResolver?
|
||||||
|
) -> String? {
|
||||||
|
if let provided = resourceResolver?.providedResourceData(at: fileURL) {
|
||||||
|
return String(data: provided, encoding: .utf8)
|
||||||
|
?? String(data: provided, encoding: .utf16)
|
||||||
|
}
|
||||||
|
return try? String(contentsOf: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static func rewriteCSSResourceURLs(
|
static func rewriteCSSResourceURLs(
|
||||||
in css: String,
|
in css: String,
|
||||||
styleSheetFileURL: URL
|
styleSheetFileURL: URL
|
||||||
|
|||||||
@ -54,7 +54,8 @@ final class RDEPUBImageViewerCoordinator {
|
|||||||
fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) }
|
fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return nil }
|
// 经统一资源入口读取:加密图片由 provider 解密,明文图片直读磁盘
|
||||||
|
guard let fileURL, let data = resourceResolver.resourceData(at: fileURL) else { return nil }
|
||||||
return UIImage(data: data)
|
return UIImage(data: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol {
|
||||||
|
|
||||||
var onShowTableOfContents: (() -> Void)?
|
var onShowTableOfContents: (() -> Void)?
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import UIKit
|
|||||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
|
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
|
||||||
|
|
||||||
public func numberOfPages(in readerView: RDReaderView) -> Int {
|
public func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||||
pageCountOfReaderView(readerView: readerView)
|
effectiveNumberOfPages
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||||
@ -28,6 +28,11 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||||
|
// 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图
|
||||||
|
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
|
||||||
|
return makeTrialWallPageView(reusableView: containerView)
|
||||||
|
}
|
||||||
|
|
||||||
if readerContext.bookPageMap != nil {
|
if readerContext.bookPageMap != nil {
|
||||||
_ = runtime.prepareOnDemandChapter(
|
_ = runtime.prepareOnDemandChapter(
|
||||||
forAbsolutePageNumber: pageNum + 1,
|
forAbsolutePageNumber: pageNum + 1,
|
||||||
@ -133,7 +138,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||||
(textBook == nil && readerContext.bookPageMap == nil)
|
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
|
||||||
|
return NSStringFromClass(RDEPUBTrialWallContainerView.self)
|
||||||
|
}
|
||||||
|
return (textBook == nil && readerContext.bookPageMap == nil)
|
||||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||||
}
|
}
|
||||||
@ -376,6 +384,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI)
|
||||||
|
if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex {
|
||||||
|
delegate?.epubReaderDidReachTrialWall(self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||||
if totalPages > 0, effectivePageNum == totalPages - 1 {
|
if totalPages > 0, effectivePageNum == totalPages - 1 {
|
||||||
delegate?.epubReaderDidReachEnd(self)
|
delegate?.epubReaderDidReachEnd(self)
|
||||||
|
|||||||
@ -173,6 +173,13 @@ extension RDEPUBReaderController {
|
|||||||
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
|
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
|
||||||
return decodedRangeInfo.start
|
return decodedRangeInfo.start
|
||||||
}
|
}
|
||||||
|
// Native text anchors carry the exact UTF-16 chapter offset and remain
|
||||||
|
// stable across font/layout changes. Prefer them over CFI recovery,
|
||||||
|
// which may temporarily resolve to the chapter's first marker while a
|
||||||
|
// new CFI map is still being prepared.
|
||||||
|
if let anchor = location.rangeAnchor?.start {
|
||||||
|
return anchor.chapterOffset
|
||||||
|
}
|
||||||
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||||
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||||
let cfi = primaryLocationCFI(for: location),
|
let cfi = primaryLocationCFI(for: location),
|
||||||
@ -184,9 +191,6 @@ extension RDEPUBReaderController {
|
|||||||
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
||||||
return cfiOffset
|
return cfiOffset
|
||||||
}
|
}
|
||||||
if let anchor = location.rangeAnchor?.start {
|
|
||||||
return anchor.chapterOffset
|
|
||||||
}
|
|
||||||
if let fragment = location.fragment,
|
if let fragment = location.fragment,
|
||||||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||||||
return offset
|
return offset
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 朝向锁定支持。
|
||||||
|
///
|
||||||
|
/// 依据 `publication.metadata.orientation`(来源 OPF rendition:orientation,
|
||||||
|
/// 或兜底的 iBooks display-options)返回受限的支持朝向,并在书籍打开后主动请求转向。
|
||||||
|
/// 未声明朝向(nil / .auto)时不干预,跟随系统与容器。
|
||||||
|
///
|
||||||
|
/// 注意:`supportedInterfaceOrientations` 只有当阅读器是当前决定朝向的控制器时才生效
|
||||||
|
/// (通常需宿主保证它被 present 或位于导航栈顶;若被包在自定义容器里,容器需转发该值)。
|
||||||
|
extension RDEPUBReaderController {
|
||||||
|
|
||||||
|
/// 由 EPUB 元数据推导的受限支持朝向;nil = 不限制(跟随系统)
|
||||||
|
var epubSupportedOrientationMask: UIInterfaceOrientationMask? {
|
||||||
|
switch publication?.metadata.orientation {
|
||||||
|
case .portrait:
|
||||||
|
return .portrait
|
||||||
|
case .landscape:
|
||||||
|
return .landscape
|
||||||
|
case .auto, .none:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
epubSupportedOrientationMask ?? super.supportedInterfaceOrientations
|
||||||
|
}
|
||||||
|
|
||||||
|
public override var shouldAutorotate: Bool {
|
||||||
|
// 已锁定单一朝向时不自动旋转
|
||||||
|
epubSupportedOrientationMask == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开书籍后按元数据主动转向(iOS 16+ 用 requestGeometryUpdate,旧系统回退到刷新支持朝向)。
|
||||||
|
/// 由加载完成流程调用。
|
||||||
|
func applyOrientationLockIfNeeded() {
|
||||||
|
guard let mask = epubSupportedOrientationMask else { return }
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
guard let scene = view.window?.windowScene else { return }
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
|
||||||
|
} else {
|
||||||
|
let target: UIInterfaceOrientation = (mask == .landscape) ? .landscapeRight : .portrait
|
||||||
|
UIDevice.current.setValue(target.rawValue, forKey: "orientation")
|
||||||
|
UIViewController.attemptRotationToDeviceOrientation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 试读墙支持。
|
||||||
|
///
|
||||||
|
/// 设计:`pageCountOfReaderView` 仍返回原始内容页数(内容页显示的总页数不受影响),
|
||||||
|
/// 仅 `numberOfPages(in:)` 在启用试读墙时 +1;试读墙页固定位于最后一个可读页之后。
|
||||||
|
/// 两种形态统一覆盖:
|
||||||
|
/// - readoor 独立试读 epub(整本都是试读章节):readableChapterCount 传 nil,墙追加在全书末尾;
|
||||||
|
/// - 整本 epub + 按章限制:readableChapterCount 传 N,超出章节页不展示(仅 bookPageMap 路径)。
|
||||||
|
extension RDEPUBReaderController {
|
||||||
|
|
||||||
|
/// 试读墙视图(首次访问经 delegate 获取并缓存)
|
||||||
|
var trialWallView: UIView? {
|
||||||
|
if let cached = cachedTrialWallView {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
guard configuration.trialPolicy != nil else { return nil }
|
||||||
|
let view = delegate?.epubReaderTrialWallView(self)
|
||||||
|
cachedTrialWallView = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否启用试读墙:配置了试读策略且宿主提供了墙视图
|
||||||
|
var isTrialWallEnabled: Bool {
|
||||||
|
configuration.trialPolicy != nil && trialWallView != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原始可读内容页数(不含试读墙页)。
|
||||||
|
/// readableChapterCount 指定且存在 bookPageMap 时按章截断,否则为全部内容页。
|
||||||
|
var readableContentPageCount: Int {
|
||||||
|
let rawCount = pageCountOfReaderView(readerView: readerView)
|
||||||
|
guard let policy = configuration.trialPolicy,
|
||||||
|
let readableChapterCount = policy.readableChapterCount,
|
||||||
|
readableChapterCount > 0,
|
||||||
|
let pageMap = readerContext.bookPageMap else {
|
||||||
|
return rawCount
|
||||||
|
}
|
||||||
|
// 第一个不可读章节(spineIndex >= readableChapterCount)的起始绝对页 = 可读页数
|
||||||
|
let firstLockedStart = pageMap.entries
|
||||||
|
.first { $0.spineIndex >= readableChapterCount }?
|
||||||
|
.absolutePageStart
|
||||||
|
return firstLockedStart ?? rawCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 试读墙页的绝对索引(0-based);未启用时为 nil
|
||||||
|
var trialWallPageIndex: Int? {
|
||||||
|
guard isTrialWallEnabled else { return nil }
|
||||||
|
return readableContentPageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供 RDReaderView 使用的有效页数:启用试读墙时为可读页数 + 1(墙页)
|
||||||
|
var effectiveNumberOfPages: Int {
|
||||||
|
guard isTrialWallEnabled else {
|
||||||
|
return pageCountOfReaderView(readerView: readerView)
|
||||||
|
}
|
||||||
|
return readableContentPageCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图)
|
||||||
|
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
|
||||||
|
let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
|
||||||
|
container.setWallView(trialWallView)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 试读墙页容器:让宿主墙视图铺满整页
|
||||||
|
final class RDEPUBTrialWallContainerView: UIView {
|
||||||
|
|
||||||
|
private weak var wallView: UIView?
|
||||||
|
|
||||||
|
func setWallView(_ view: UIView?) {
|
||||||
|
guard wallView !== view else { return }
|
||||||
|
wallView?.removeFromSuperview()
|
||||||
|
guard let view else { return }
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
view.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
view.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
wallView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -146,6 +146,9 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
lazy var searchBarView = RDEPUBReaderSearchBarView()
|
lazy var searchBarView = RDEPUBReaderSearchBarView()
|
||||||
|
|
||||||
|
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
|
||||||
|
var cachedTrialWallView: UIView?
|
||||||
|
|
||||||
private(set) var isSearchBarVisible = false
|
private(set) var isSearchBarVisible = false
|
||||||
|
|
||||||
var currentBookIdentifier: String? {
|
var currentBookIdentifier: String? {
|
||||||
|
|||||||
@ -26,7 +26,15 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
|||||||
|
|
||||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||||
|
|
||||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding)
|
||||||
|
|
||||||
|
/// 提供试读墙视图(configuration.trialPolicy 非 nil 时生效)。
|
||||||
|
/// 返回非 nil 时,阅读器在最后一个可读页之后追加一页展示该视图;返回 nil 则不追加试读墙。
|
||||||
|
/// 视图的购买 / 登录按钮由宿主自行处理(可在点击后调用 reloadBook() 切换到完整 epub)。
|
||||||
|
func epubReaderTrialWallView(_ reader: UIViewController) -> UIView?
|
||||||
|
|
||||||
|
/// 用户翻到试读墙页时回调(宿主可在此触发购买引导 / 埋点等)。
|
||||||
|
func epubReaderDidReachTrialWall(_ reader: UIViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension RDEPUBReaderDelegate {
|
public extension RDEPUBReaderDelegate {
|
||||||
@ -42,5 +50,7 @@ public extension RDEPUBReaderDelegate {
|
|||||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
||||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding) {}
|
||||||
|
func epubReaderTrialWallView(_ reader: UIViewController) -> UIView? { nil }
|
||||||
|
func epubReaderDidReachTrialWall(_ reader: UIViewController) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 顶部工具栏协议:宿主可提供自定义顶栏,只需实现以下回调与状态方法。
|
||||||
|
/// 内置 `RDEPUBReaderTopToolView` 已实现本协议。
|
||||||
|
/// 通过 `RDEPUBReaderDependencies.makeTopToolView` 工厂注入自定义实现。
|
||||||
|
public protocol RDEPUBReaderTopToolViewProtocol: AnyObject {
|
||||||
|
|
||||||
|
/// 返回 / 关闭
|
||||||
|
var onBack: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 搜索
|
||||||
|
var onSearch: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 切换书签
|
||||||
|
var onToggleBookmark: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 应用主题
|
||||||
|
func apply(theme: RDEPUBReaderTheme)
|
||||||
|
|
||||||
|
/// 设置标题
|
||||||
|
func setTitle(_ title: String?)
|
||||||
|
|
||||||
|
/// 书签按钮是否可用
|
||||||
|
func setBookmarkEnabled(_ isEnabled: Bool)
|
||||||
|
|
||||||
|
/// 书签按钮选中态(当前页是否已加书签)
|
||||||
|
func setBookmarkSelected(_ isSelected: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部工具栏协议:宿主可提供自定义底栏。内置 `RDEPUBReaderBottomToolView` 已实现本协议。
|
||||||
|
/// 通过 `RDEPUBReaderDependencies.makeBottomToolView` 工厂注入自定义实现。
|
||||||
|
public protocol RDEPUBReaderBottomToolViewProtocol: AnyObject {
|
||||||
|
|
||||||
|
/// 目录
|
||||||
|
var onShowTableOfContents: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 书签列表
|
||||||
|
var onShowBookmarks: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 高亮列表
|
||||||
|
var onShowHighlights: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 新增高亮 / 标注
|
||||||
|
var onAddHighlight: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 设置面板
|
||||||
|
var onShowSettings: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// 应用主题
|
||||||
|
func apply(theme: RDEPUBReaderTheme)
|
||||||
|
|
||||||
|
/// 按配置更新各入口可见性
|
||||||
|
func updateVisibility(showsTableOfContents: Bool, allowsHighlights: Bool, showsSettingsPanel: Bool)
|
||||||
|
|
||||||
|
/// 书签列表按钮是否可用
|
||||||
|
func setBookmarksEnabled(_ isEnabled: Bool)
|
||||||
|
|
||||||
|
/// 新增高亮按钮是否可用(有选区时)
|
||||||
|
func setAddHighlightEnabled(_ isEnabled: Bool)
|
||||||
|
|
||||||
|
/// 高亮列表按钮是否可用
|
||||||
|
func setHighlightsEnabled(_ isEnabled: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶部工具栏具体类型:UIView 且实现顶栏协议
|
||||||
|
public typealias RDEPUBReaderTopToolViewProviding = UIView & RDEPUBReaderTopToolViewProtocol
|
||||||
|
|
||||||
|
/// 底部工具栏具体类型:UIView 且实现底栏协议
|
||||||
|
public typealias RDEPUBReaderBottomToolViewProviding = UIView & RDEPUBReaderBottomToolViewProtocol
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView, RDEPUBReaderTopToolViewProtocol {
|
||||||
|
|
||||||
var onBack: (() -> Void)?
|
public var onBack: (() -> Void)?
|
||||||
|
|
||||||
var onToggleBookmark: (() -> Void)?
|
public var onToggleBookmark: (() -> Void)?
|
||||||
|
|
||||||
var onSearch: (() -> Void)?
|
public var onSearch: (() -> Void)?
|
||||||
|
|
||||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||||
private let searchButton = RDEPUBReaderTintButton(type: .system)
|
private let searchButton = RDEPUBReaderTintButton(type: .system)
|
||||||
@ -98,16 +98,16 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
updateBookmarkButtonAppearance()
|
updateBookmarkButtonAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTitle(_ title: String?) {
|
public func setTitle(_ title: String?) {
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBookmarkSelected(_ isSelected: Bool) {
|
public func setBookmarkSelected(_ isSelected: Bool) {
|
||||||
isBookmarked = isSelected
|
isBookmarked = isSelected
|
||||||
updateBookmarkButtonAppearance()
|
updateBookmarkButtonAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBookmarkEnabled(_ isEnabled: Bool) {
|
public func setBookmarkEnabled(_ isEnabled: Bool) {
|
||||||
bookmarkButton.isEnabled = isEnabled
|
bookmarkButton.isEnabled = isEnabled
|
||||||
bookmarkButton.alpha = isEnabled ? 1 : 0.45
|
bookmarkButton.alpha = isEnabled ? 1 : 0.45
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,8 +17,9 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
context.runtime?.settingsPanelDidDisappear()
|
context.runtime?.settingsPanelDidDisappear()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||||
let toolView = RDEPUBReaderTopToolView()
|
let toolView: RDEPUBReaderTopToolViewProviding =
|
||||||
|
context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView()
|
||||||
toolView.onBack = { [weak self] in
|
toolView.onBack = { [weak self] in
|
||||||
self?.handleBackAction()
|
self?.handleBackAction()
|
||||||
}
|
}
|
||||||
@ -31,8 +32,9 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
return toolView
|
return toolView
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||||
let toolView = RDEPUBReaderBottomToolView()
|
let toolView: RDEPUBReaderBottomToolViewProviding =
|
||||||
|
context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView()
|
||||||
toolView.onShowTableOfContents = { [weak self] in
|
toolView.onShowTableOfContents = { [weak self] in
|
||||||
self?.presentTableOfContents()
|
self?.presentTableOfContents()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,13 +35,21 @@ public struct RDEPUBReaderDependencies {
|
|||||||
|
|
||||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||||
|
|
||||||
|
/// 自定义顶部工具栏工厂;nil 时使用内置 `RDEPUBReaderTopToolView`
|
||||||
|
public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)?
|
||||||
|
|
||||||
|
/// 自定义底部工具栏工厂;nil 时使用内置 `RDEPUBReaderBottomToolView`
|
||||||
|
public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
environment: any RDEPUBReaderDisplayEnvironment,
|
environment: any RDEPUBReaderDisplayEnvironment,
|
||||||
makeParser: @escaping () -> RDEPUBParser,
|
makeParser: @escaping () -> RDEPUBParser,
|
||||||
makePaginator: @escaping () -> RDEPUBPaginator,
|
makePaginator: @escaping () -> RDEPUBPaginator,
|
||||||
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
||||||
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
|
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
|
||||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer,
|
||||||
|
makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil,
|
||||||
|
makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.makeParser = makeParser
|
self.makeParser = makeParser
|
||||||
@ -49,6 +57,22 @@ public struct RDEPUBReaderDependencies {
|
|||||||
self.makeTextBookBuilder = makeTextBookBuilder
|
self.makeTextBookBuilder = makeTextBookBuilder
|
||||||
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
||||||
self.makeTextRenderer = makeTextRenderer
|
self.makeTextRenderer = makeTextRenderer
|
||||||
|
self.makeTopToolView = makeTopToolView
|
||||||
|
self.makeBottomToolView = makeBottomToolView
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建带加密资源 provider 的默认依赖:
|
||||||
|
/// 宿主实现 `RDEPUBResourceDataProvider` 后经此注入,即可打开单文件加密的 EPUB。
|
||||||
|
/// - Parameter resourceDataProvider: 解密数据提供者;传 nil 等价于 `.live`
|
||||||
|
public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies {
|
||||||
|
var dependencies = RDEPUBReaderDependencies.live
|
||||||
|
guard let resourceDataProvider else { return dependencies }
|
||||||
|
dependencies.makeParser = {
|
||||||
|
let parser = RDEPUBParser()
|
||||||
|
parser.resourceDataProvider = resourceDataProvider
|
||||||
|
return parser
|
||||||
|
}
|
||||||
|
return dependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var live: RDEPUBReaderDependencies {
|
public static var live: RDEPUBReaderDependencies {
|
||||||
|
|||||||
@ -80,6 +80,7 @@ final class RDEPUBReaderLoadCoordinator {
|
|||||||
controller.applyReaderViewConfiguration()
|
controller.applyReaderViewConfiguration()
|
||||||
context.updateReaderChrome()
|
context.updateReaderChrome()
|
||||||
controller.delegate?.epubReader(controller, didOpen: publication)
|
controller.delegate?.epubReader(controller, didOpen: publication)
|
||||||
|
controller.applyOrientationLockIfNeeded()
|
||||||
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,9 +114,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||||
}
|
}
|
||||||
|
|
||||||
func repaginatePreservingCurrentLocation() {
|
func repaginatePreservingCurrentLocation(
|
||||||
|
preferredRestoreLocation: RDEPUBLocation? = nil
|
||||||
|
) {
|
||||||
guard context.publication != nil else { return }
|
guard context.publication != nil else { return }
|
||||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
let restoreLocation = preferredRestoreLocation
|
||||||
|
?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||||
?? context.currentVisibleLocation()
|
?? context.currentVisibleLocation()
|
||||||
?? context.persistenceLocation()
|
?? context.persistenceLocation()
|
||||||
paginatePublication(restoreLocation: restoreLocation)
|
paginatePublication(restoreLocation: restoreLocation)
|
||||||
|
|||||||
@ -69,6 +69,15 @@ final class RDEPUBReaderRuntime {
|
|||||||
|
|
||||||
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
/// The location before the settings preview replaces the full book page map
|
||||||
|
/// with the current chapter's temporary map. The temporary map rebases that
|
||||||
|
/// chapter at page zero, so it must never be used as the final restore source.
|
||||||
|
private var settingsRestoreLocation: RDEPUBLocation?
|
||||||
|
|
||||||
|
/// A settings session must keep one immutable text offset. Re-capturing the
|
||||||
|
/// start of each preview page makes repeated font-size changes drift backward.
|
||||||
|
private var settingsPreviewAnchor: SettingsPreviewAnchor?
|
||||||
|
|
||||||
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
||||||
|
|
||||||
private struct SettingsPreviewAnchor {
|
private struct SettingsPreviewAnchor {
|
||||||
@ -84,11 +93,11 @@ final class RDEPUBReaderRuntime {
|
|||||||
self.context = context
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||||
chromeCoordinator.makeTopToolView()
|
chromeCoordinator.makeTopToolView()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||||
chromeCoordinator.makeBottomToolView()
|
chromeCoordinator.makeBottomToolView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,7 +388,8 @@ final class RDEPUBReaderRuntime {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.pendingSettingsPreviewWorkItem = nil
|
self.pendingSettingsPreviewWorkItem = nil
|
||||||
let previewAnchor = self.captureSettingsPreviewAnchor()
|
let previewAnchor = self.settingsPreviewAnchor
|
||||||
|
?? self.captureSettingsPreviewAnchor()
|
||||||
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||||
self.repaginateCurrentChapterOnly(
|
self.repaginateCurrentChapterOnly(
|
||||||
previewGeneration: previewGeneration,
|
previewGeneration: previewGeneration,
|
||||||
@ -504,6 +514,13 @@ final class RDEPUBReaderRuntime {
|
|||||||
pendingSettingsPreviewWorkItem = nil
|
pendingSettingsPreviewWorkItem = nil
|
||||||
isSettingsPanelOpen = true
|
isSettingsPanelOpen = true
|
||||||
needsFullRepaginationAfterSettingsClose = false
|
needsFullRepaginationAfterSettingsClose = false
|
||||||
|
let fallbackLocation = locationCoordinator.currentVisibleLocation()
|
||||||
|
?? context.persistenceLocation()
|
||||||
|
settingsPreviewAnchor = captureSettingsPreviewAnchor()
|
||||||
|
settingsRestoreLocation = exactSettingsRestoreLocation(
|
||||||
|
anchor: settingsPreviewAnchor,
|
||||||
|
fallbackLocation: fallbackLocation
|
||||||
|
)
|
||||||
settingsPreviewGeneration += 1
|
settingsPreviewGeneration += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,8 +532,35 @@ final class RDEPUBReaderRuntime {
|
|||||||
|
|
||||||
if needsFullRepaginationAfterSettingsClose {
|
if needsFullRepaginationAfterSettingsClose {
|
||||||
needsFullRepaginationAfterSettingsClose = false
|
needsFullRepaginationAfterSettingsClose = false
|
||||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
paginationCoordinator.repaginatePreservingCurrentLocation(
|
||||||
|
preferredRestoreLocation: settingsRestoreLocation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
settingsRestoreLocation = nil
|
||||||
|
settingsPreviewAnchor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exactSettingsRestoreLocation(
|
||||||
|
anchor: SettingsPreviewAnchor?,
|
||||||
|
fallbackLocation: RDEPUBLocation?
|
||||||
|
) -> RDEPUBLocation? {
|
||||||
|
guard let anchor else { return fallbackLocation }
|
||||||
|
|
||||||
|
let textAnchor = RDEPUBTextAnchor(
|
||||||
|
fileIndex: anchor.spineIndex,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
chapterOffset: anchor.offset,
|
||||||
|
fragmentID: fallbackLocation?.fragment
|
||||||
|
)
|
||||||
|
return RDEPUBLocation(
|
||||||
|
bookIdentifier: fallbackLocation?.bookIdentifier ?? context.currentBookIdentifier,
|
||||||
|
href: anchor.href,
|
||||||
|
progression: fallbackLocation?.progression ?? 0,
|
||||||
|
lastProgression: fallbackLocation?.lastProgression,
|
||||||
|
fragment: fallbackLocation?.fragment,
|
||||||
|
rangeAnchor: RDEPUBTextRangeAnchor(start: textAnchor, end: textAnchor)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshVisibleContentPreservingLocation() {
|
func refreshVisibleContentPreservingLocation() {
|
||||||
|
|||||||
@ -46,6 +46,19 @@ public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 试读策略:开启后,阅读器在最后一个可读页之后追加一页“试读墙”(UI 由宿主经 delegate 提供)。
|
||||||
|
public struct RDEPUBReaderTrialPolicy: Equatable {
|
||||||
|
|
||||||
|
/// 可读章节数(可读 spine 为 0..<readableChapterCount)。
|
||||||
|
/// nil = 全书可读,试读墙追加在全书末尾(对应 readoor 的独立试读 epub:整本都是试读章节)。
|
||||||
|
/// 指定数值时超出范围的章节页不再展示(对应“整本 epub + 按章限制”形态,仅 bookPageMap 路径生效)。
|
||||||
|
public var readableChapterCount: Int?
|
||||||
|
|
||||||
|
public init(readableChapterCount: Int? = nil) {
|
||||||
|
self.readableChapterCount = readableChapterCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct RDEPUBReaderConfiguration: Equatable {
|
public struct RDEPUBReaderConfiguration: Equatable {
|
||||||
|
|
||||||
public var fontSize: CGFloat
|
public var fontSize: CGFloat
|
||||||
@ -98,6 +111,9 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
|
|
||||||
public var enablesVerboseWebViewLogging: Bool
|
public var enablesVerboseWebViewLogging: Bool
|
||||||
|
|
||||||
|
/// 试读策略;nil = 非试读(全书可读、无试读墙)。
|
||||||
|
public var trialPolicy: RDEPUBReaderTrialPolicy?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
fontSize: CGFloat = 15,
|
fontSize: CGFloat = 15,
|
||||||
lineHeightMultiple: CGFloat = 1.6,
|
lineHeightMultiple: CGFloat = 1.6,
|
||||||
@ -123,7 +139,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
allowedExternalURLSchemes: Set<String> = ["https"],
|
allowedExternalURLSchemes: Set<String> = ["https"],
|
||||||
requiresExternalLinkConfirmation: Bool = true,
|
requiresExternalLinkConfirmation: Bool = true,
|
||||||
allowsInspectableWebViews: Bool = false,
|
allowsInspectableWebViews: Bool = false,
|
||||||
enablesVerboseWebViewLogging: Bool = false
|
enablesVerboseWebViewLogging: Bool = false,
|
||||||
|
trialPolicy: RDEPUBReaderTrialPolicy? = nil
|
||||||
) {
|
) {
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
self.lineHeightMultiple = lineHeightMultiple
|
self.lineHeightMultiple = lineHeightMultiple
|
||||||
@ -150,6 +167,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
||||||
self.allowsInspectableWebViews = allowsInspectableWebViews
|
self.allowsInspectableWebViews = allowsInspectableWebViews
|
||||||
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||||
|
self.trialPolicy = trialPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = RDEPUBReaderConfiguration()
|
public static let `default` = RDEPUBReaderConfiguration()
|
||||||
|
|||||||
@ -584,6 +584,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
|
|
||||||
private func image(from attachmentValue: Any?) -> UIImage? {
|
private func image(from attachmentValue: Any?) -> UIImage? {
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
|
// 附件本体已持有图片时(含加密书解密后的图)优先使用,避免二次读盘或误读密文
|
||||||
|
if let attachment = attachmentValue as? DTImageTextAttachment,
|
||||||
|
let image = attachment.image {
|
||||||
|
return image
|
||||||
|
}
|
||||||
if let attachment = attachmentValue as? DTTextAttachment,
|
if let attachment = attachmentValue as? DTTextAttachment,
|
||||||
let url = attachment.contentURL {
|
let url = attachment.contentURL {
|
||||||
return UIImage(contentsOfFile: url.path)
|
return UIImage(contentsOfFile: url.path)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user