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:
co-authored by
Claude Fable 5
parent
83dfa40299
commit
d5a7755702
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user