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
@@ -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 epubreadableChapterCount 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)