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
parent 83dfa40299
commit d5a7755702
30 changed files with 2218 additions and 1569 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 */

View File

@ -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)[^>]*>"#

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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
}
}

View File

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

View File

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

View File

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

View File

@ -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,9 +106,19 @@ 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(
in css: String,
styleSheetFileURL: URL

View File

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

View File

@ -1,6 +1,6 @@
import UIKit
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol {
var onShowTableOfContents: (() -> Void)?

View File

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

View File

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

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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? {

View File

@ -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) {}
}

View File

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

View File

@ -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
}

View File

@ -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()
}

View File

@ -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 {

View File

@ -80,6 +80,7 @@ final class RDEPUBReaderLoadCoordinator {
controller.applyReaderViewConfiguration()
context.updateReaderChrome()
controller.delegate?.epubReader(controller, didOpen: publication)
controller.applyOrientationLockIfNeeded()
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
}
}

View File

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

View File

@ -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() {

View File

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

View File

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