ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift
shenlei d5a7755702 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>
2026-07-10 12:25:17 +09:00

320 lines
12 KiB
Swift

import UIKit
extension RDEPUBReaderController {
func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex = resolvedLocalPageIndex(
for: normalizedLocation,
spineIndex: spineIndex,
fallbackEntry: entry,
rangeInfo: rangeInfo
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook {
let normalizedLocation = publication?.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) {
return pageNumber
}
if let anchor = normalizedLocation.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
if let publication {
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
// External text books have no resolver; hrefs match verbatim.
return textBook.chapterData(for: normalizedLocation.href)?
.pageNumber(for: normalizedLocation)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
func currentVisibleRangeInfo() -> String? {
guard let pageNumber = currentPageNumber else {
return nil
}
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset + 1, resolvedPage.page.pageEndOffset + 1)
return RDEPUBTextOffsetRangeInfo(
href: resolvedPage.chapter.href,
start: startOffset,
end: endOffset
).jsonString()
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let startOffset = page.pageStartOffset
let endOffset = max(startOffset + 1, page.pageEndOffset + 1)
return RDEPUBTextOffsetRangeInfo(
href: page.href,
start: startOffset,
end: endOffset
).jsonString()
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int {
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),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: cfi) {
return offset
}
if let rawCFI = primaryLocationCFI(for: location),
let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
private func resolvedLocalPageIndex(
for location: RDEPUBLocation,
spineIndex: Int,
fallbackEntry: RDEPUBBookPageMapEntry,
rangeInfo: String? = nil
) -> Int? {
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo)
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
return pageIndex
}
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
return summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
}
}
return nil
}
private func primaryLocationCFI(for location: RDEPUBLocation) -> String? {
if let rangeCFI = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI) {
return rangeCFI.start.rawValue
}
return location.cfi
}
private func pageNumberFromRangeInfo(
_ rangeInfo: String?,
in textBook: RDEPUBTextBook,
matching location: RDEPUBLocation
) -> Int? {
guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location),
let chapterData = chapterData(in: textBook, for: decodedRangeInfo),
let page = chapterData.page(containing: decodedRangeInfo.start) else {
return nil
}
return page.absolutePageIndex + 1
}
private func decodedRangeInfo(
from rangeInfo: String?,
matching location: RDEPUBLocation
) -> RDEPUBTextOffsetRangeInfo? {
guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else {
return nil
}
return decodedRangeInfo
}
private func chapterData(
in textBook: RDEPUBTextBook,
for rangeInfo: RDEPUBTextOffsetRangeInfo
) -> RDEPUBChapterData? {
if let chapterData = textBook.chapterData(for: rangeInfo.href) {
return chapterData
}
let targetHref = normalizedHref(rangeInfo.href)
if let chapterData = textBook.chapterData(for: targetHref) {
return chapterData
}
guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else {
return nil
}
return textBook.chapterData(for: chapter.href)
}
private func normalizedHref(_ href: String) -> String {
publication?.resourceResolver.normalizedHref(href)
?? href.components(separatedBy: "#").first
?? href
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
private func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}