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:
shenlei
2026-07-10 12:25:17 +09:00
co-authored by Claude Fable 5
parent 83dfa40299
commit d5a7755702
30 changed files with 2218 additions and 1569 deletions
@@ -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))