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 */; };
|
||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.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 */; };
|
||||
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 */; };
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -65,7 +66,6 @@
|
||||
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>"; };
|
||||
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; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@ -59,13 +59,22 @@ public final class RDEPUBNoteResolver {
|
||||
guard let fragment,
|
||||
!fragment.isEmpty,
|
||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||||
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
|
||||
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)[^>]*>"#
|
||||
|
||||
@ -24,6 +24,13 @@ public enum RDEPUBPageSpread: String, Codable {
|
||||
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?
|
||||
@ -42,6 +49,10 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
|
||||
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 = "",
|
||||
@ -50,7 +61,8 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
version: String? = nil,
|
||||
layout: RDEPUBLayout = .reflowable,
|
||||
spread: String? = nil,
|
||||
readingProgression: RDEPUBReadingProgression = .auto
|
||||
readingProgression: RDEPUBReadingProgression = .auto,
|
||||
orientation: RDEPUBOrientation? = nil
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
@ -60,6 +72,7 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
self.layout = layout
|
||||
self.spread = spread
|
||||
self.readingProgression = readingProgression
|
||||
self.orientation = orientation
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -151,3 +151,48 @@ private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
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" {
|
||||
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,
|
||||
@ -270,6 +275,8 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
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
|
||||
@ -315,6 +322,19 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
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 {
|
||||
|
||||
@ -62,7 +62,8 @@ extension RDEPUBParser {
|
||||
}
|
||||
for item in coverCandidates {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -80,6 +81,10 @@ extension RDEPUBParser {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,28 @@ public final class RDEPUBParser {
|
||||
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
|
||||
@ -101,6 +123,37 @@ public final class RDEPUBParser {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/// 是否配置了加密资源数据提供者
|
||||
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
|
||||
|
||||
@ -87,7 +87,9 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
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)
|
||||
} else {
|
||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||
@ -133,7 +135,13 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
return
|
||||
}
|
||||
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 {
|
||||
DispatchQueue.main.async {
|
||||
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)
|
||||
|
||||
if let fileURL = resolution.resolvedFileURL,
|
||||
let css = try? String(contentsOf: fileURL),
|
||||
let css = styleSheetContent(at: fileURL, resourceResolver: resourceResolver),
|
||||
resolution.diagnostic.existsOnDisk {
|
||||
let cssWithResolvedURLs = rewriteCSSResourceURLs(
|
||||
in: css,
|
||||
@ -106,7 +106,17 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
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(
|
||||
|
||||
@ -54,7 +54,8 @@ final class RDEPUBImageViewerCoordinator {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol {
|
||||
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import UIKit
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
|
||||
|
||||
public func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||
pageCountOfReaderView(readerView: readerView)
|
||||
effectiveNumberOfPages
|
||||
}
|
||||
|
||||
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 {
|
||||
// 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图
|
||||
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
|
||||
return makeTrialWallPageView(reusableView: containerView)
|
||||
}
|
||||
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNum + 1,
|
||||
@ -133,7 +138,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
}
|
||||
|
||||
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(RDEPUBTextContentView.self)
|
||||
}
|
||||
@ -376,6 +384,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
||||
}
|
||||
|
||||
// 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI)
|
||||
if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex {
|
||||
delegate?.epubReaderDidReachTrialWall(self)
|
||||
return
|
||||
}
|
||||
|
||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||
if totalPages > 0, effectivePageNum == totalPages - 1 {
|
||||
delegate?.epubReaderDidReachEnd(self)
|
||||
|
||||
@ -173,6 +173,13 @@ extension RDEPUBReaderController {
|
||||
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
|
||||
return decodedRangeInfo.start
|
||||
}
|
||||
// Native text anchors carry the exact UTF-16 chapter offset and remain
|
||||
// stable across font/layout changes. Prefer them over CFI recovery,
|
||||
// which may temporarily resolve to the chapter's first marker while a
|
||||
// new CFI map is still being prepared.
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
let cfi = primaryLocationCFI(for: location),
|
||||
@ -184,9 +191,6 @@ extension RDEPUBReaderController {
|
||||
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
||||
return cfiOffset
|
||||
}
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
if let fragment = location.fragment,
|
||||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||||
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()
|
||||
|
||||
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
|
||||
var cachedTrialWallView: UIView?
|
||||
|
||||
private(set) var isSearchBarVisible = false
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
|
||||
@ -26,7 +26,15 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
|
||||
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 {
|
||||
@ -42,5 +50,7 @@ public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: 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
|
||||
|
||||
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 searchButton = RDEPUBReaderTintButton(type: .system)
|
||||
@ -98,16 +98,16 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setTitle(_ title: String?) {
|
||||
public func setTitle(_ title: String?) {
|
||||
titleLabel.text = title
|
||||
}
|
||||
|
||||
func setBookmarkSelected(_ isSelected: Bool) {
|
||||
public func setBookmarkSelected(_ isSelected: Bool) {
|
||||
isBookmarked = isSelected
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setBookmarkEnabled(_ isEnabled: Bool) {
|
||||
public func setBookmarkEnabled(_ isEnabled: Bool) {
|
||||
bookmarkButton.isEnabled = isEnabled
|
||||
bookmarkButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
@ -17,8 +17,9 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||
let toolView: RDEPUBReaderTopToolViewProviding =
|
||||
context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
self?.handleBackAction()
|
||||
}
|
||||
@ -31,8 +32,9 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
return toolView
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||
let toolView: RDEPUBReaderBottomToolViewProviding =
|
||||
context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
self?.presentTableOfContents()
|
||||
}
|
||||
|
||||
@ -35,13 +35,21 @@ public struct RDEPUBReaderDependencies {
|
||||
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
/// 自定义顶部工具栏工厂;nil 时使用内置 `RDEPUBReaderTopToolView`
|
||||
public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)?
|
||||
|
||||
/// 自定义底部工具栏工厂;nil 时使用内置 `RDEPUBReaderBottomToolView`
|
||||
public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)?
|
||||
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
makePaginator: @escaping () -> RDEPUBPaginator,
|
||||
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
||||
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
|
||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer,
|
||||
makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil,
|
||||
makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil
|
||||
) {
|
||||
self.environment = environment
|
||||
self.makeParser = makeParser
|
||||
@ -49,6 +57,22 @@ public struct RDEPUBReaderDependencies {
|
||||
self.makeTextBookBuilder = makeTextBookBuilder
|
||||
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
self.makeTopToolView = makeTopToolView
|
||||
self.makeBottomToolView = makeBottomToolView
|
||||
}
|
||||
|
||||
/// 构建带加密资源 provider 的默认依赖:
|
||||
/// 宿主实现 `RDEPUBResourceDataProvider` 后经此注入,即可打开单文件加密的 EPUB。
|
||||
/// - Parameter resourceDataProvider: 解密数据提供者;传 nil 等价于 `.live`
|
||||
public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies {
|
||||
var dependencies = RDEPUBReaderDependencies.live
|
||||
guard let resourceDataProvider else { return dependencies }
|
||||
dependencies.makeParser = {
|
||||
let parser = RDEPUBParser()
|
||||
parser.resourceDataProvider = resourceDataProvider
|
||||
return parser
|
||||
}
|
||||
return dependencies
|
||||
}
|
||||
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
|
||||
@ -80,6 +80,7 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
controller.applyReaderViewConfiguration()
|
||||
context.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didOpen: publication)
|
||||
controller.applyOrientationLockIfNeeded()
|
||||
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,9 +114,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
func repaginatePreservingCurrentLocation(
|
||||
preferredRestoreLocation: RDEPUBLocation? = nil
|
||||
) {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
let restoreLocation = preferredRestoreLocation
|
||||
?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
?? context.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
|
||||
@ -69,6 +69,15 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
||||
|
||||
/// The location before the settings preview replaces the full book page map
|
||||
/// with the current chapter's temporary map. The temporary map rebases that
|
||||
/// chapter at page zero, so it must never be used as the final restore source.
|
||||
private var settingsRestoreLocation: RDEPUBLocation?
|
||||
|
||||
/// A settings session must keep one immutable text offset. Re-capturing the
|
||||
/// start of each preview page makes repeated font-size changes drift backward.
|
||||
private var settingsPreviewAnchor: SettingsPreviewAnchor?
|
||||
|
||||
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
||||
|
||||
private struct SettingsPreviewAnchor {
|
||||
@ -84,11 +93,11 @@ final class RDEPUBReaderRuntime {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
@ -379,7 +388,8 @@ final class RDEPUBReaderRuntime {
|
||||
return
|
||||
}
|
||||
self.pendingSettingsPreviewWorkItem = nil
|
||||
let previewAnchor = self.captureSettingsPreviewAnchor()
|
||||
let previewAnchor = self.settingsPreviewAnchor
|
||||
?? self.captureSettingsPreviewAnchor()
|
||||
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
self.repaginateCurrentChapterOnly(
|
||||
previewGeneration: previewGeneration,
|
||||
@ -504,6 +514,13 @@ final class RDEPUBReaderRuntime {
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
isSettingsPanelOpen = true
|
||||
needsFullRepaginationAfterSettingsClose = false
|
||||
let fallbackLocation = locationCoordinator.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
settingsPreviewAnchor = captureSettingsPreviewAnchor()
|
||||
settingsRestoreLocation = exactSettingsRestoreLocation(
|
||||
anchor: settingsPreviewAnchor,
|
||||
fallbackLocation: fallbackLocation
|
||||
)
|
||||
settingsPreviewGeneration += 1
|
||||
}
|
||||
|
||||
@ -515,8 +532,35 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
if needsFullRepaginationAfterSettingsClose {
|
||||
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() {
|
||||
|
||||
@ -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 var fontSize: CGFloat
|
||||
@ -98,6 +111,9 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
|
||||
public var enablesVerboseWebViewLogging: Bool
|
||||
|
||||
/// 试读策略;nil = 非试读(全书可读、无试读墙)。
|
||||
public var trialPolicy: RDEPUBReaderTrialPolicy?
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@ -123,7 +139,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
allowedExternalURLSchemes: Set<String> = ["https"],
|
||||
requiresExternalLinkConfirmation: Bool = true,
|
||||
allowsInspectableWebViews: Bool = false,
|
||||
enablesVerboseWebViewLogging: Bool = false
|
||||
enablesVerboseWebViewLogging: Bool = false,
|
||||
trialPolicy: RDEPUBReaderTrialPolicy? = nil
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
@ -150,6 +167,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
||||
self.allowsInspectableWebViews = allowsInspectableWebViews
|
||||
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||
self.trialPolicy = trialPolicy
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
|
||||
@ -584,6 +584,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
|
||||
private func image(from attachmentValue: Any?) -> UIImage? {
|
||||
#if canImport(DTCoreText)
|
||||
// 附件本体已持有图片时(含加密书解密后的图)优先使用,避免二次读盘或误读密文
|
||||
if let attachment = attachmentValue as? DTImageTextAttachment,
|
||||
let image = attachment.image {
|
||||
return image
|
||||
}
|
||||
if let attachment = attachmentValue as? DTTextAttachment,
|
||||
let url = attachment.contentURL {
|
||||
return UIImage(contentsOfFile: url.path)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user