feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -0,0 +1,24 @@
import UIKit
final class RDEPUBNotePopupCoordinator {
static func present(
_ note: RDEPUBResolvedNote,
from controller: UIViewController,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
let popup = RDEPUBNotePopupViewController(
note: note,
onReturnToSource: onReturnToSource,
onOpenNoteLocation: onOpenNoteLocation
)
let navigationController = UINavigationController(rootViewController: popup)
navigationController.modalPresentationStyle = .pageSheet
if let sheet = navigationController.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
controller.present(navigationController, animated: true)
}
}
@@ -0,0 +1,145 @@
import UIKit
final class RDEPUBNotePopupViewController: UIViewController {
private let note: RDEPUBResolvedNote
private let onReturnToSource: (() -> Void)?
private let onOpenNoteLocation: (() -> Void)?
private let textView = UITextView()
private let actionsStackView = UIStackView()
init(
note: RDEPUBResolvedNote,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
self.note = note
self.onReturnToSource = onReturnToSource
self.onOpenNoteLocation = onOpenNoteLocation
super.init(nibName: nil, bundle: nil)
title = note.title ?? "注释"
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(close)
)
textView.translatesAutoresizingMaskIntoConstraints = false
textView.isEditable = false
textView.alwaysBounceVertical = true
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
textView.attributedText = attributedContent()
textView.accessibilityIdentifier = "epub.reader.note.text"
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
actionsStackView.axis = .horizontal
actionsStackView.spacing = 12
actionsStackView.distribution = .fillEqually
actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
configureActionButtons()
view.addSubview(actionsStackView)
view.addSubview(textView)
NSLayoutConstraint.activate([
actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func configureActionButtons() {
if note.sourceLocation != nil, onReturnToSource != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "返回原文", action: #selector(returnToSource))
)
}
if onOpenNoteLocation != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
)
}
actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
}
private func makeActionButton(title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.title = title
configuration.baseBackgroundColor = .secondarySystemBackground
configuration.baseForegroundColor = .label
button.configuration = configuration
if title == "返回原文" {
button.accessibilityIdentifier = "epub.reader.note.return"
} else if title == "打开注释原文" {
button.accessibilityIdentifier = "epub.reader.note.open"
}
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func attributedContent() -> NSAttributedString {
let html = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font: -apple-system-body;
color: #1f1f1f;
line-height: 1.55;
}
a { color: #3b6ea8; }
img, svg, table { max-width: 100%; height: auto; }
</style>
</head>
<body>\(note.html)</body>
</html>
"""
guard let data = html.data(using: .utf8),
let attributed = try? NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return NSAttributedString(string: note.plainText)
}
return attributed
}
@objc private func close() {
dismiss(animated: true)
}
@objc private func returnToSource() {
dismiss(animated: true) { [onReturnToSource] in
onReturnToSource?()
}
}
@objc private func openNoteLocation() {
dismiss(animated: true) { [onOpenNoteLocation] in
onOpenNoteLocation?()
}
}
}
@@ -1,27 +1,17 @@
import UIKit
// MARK: -
///
///
/// configuration
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
// MARK:
///
var onShowTableOfContents: (() -> Void)?
///
var onShowBookmarks: (() -> Void)?
///
var onShowHighlights: (() -> Void)?
///
var onAddHighlight: (() -> Void)?
///
var onShowSettings: (() -> Void)?
// MARK: UI
///
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
@@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
return view
}()
///
private let chapterButton = RDEPUBReaderTintButton(type: .system)
///
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
///
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
///
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
///
private let settingsButton = RDEPUBReaderTintButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.bottomToolbar"
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(chapterButton)
@@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
}
}
// MARK:
///
func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45
@@ -1,15 +1,13 @@
import UIKit
// MARK: -
/// EPUB
///
final class RDEPUBReaderChapterListController: UITableViewController {
///
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]
private let currentItem: RDEPUBReaderTableOfContentsItem?
private let theme: RDEPUBReaderTheme
init(
@@ -1,19 +1,8 @@
// RDEPUBReaderController+ContentDelegates.swift
// EPUB
// Web RDEPUBWebContentViewDelegate Native Text
// RDEPUBTextContentViewDelegate
import UIKit
/// RDEPUBReaderController
///
/// EPUB Web Native Text
///
// MARK: - Web EPUB /Web
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
@@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
@@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
return
}
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
@@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
/// Web JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
@@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
// MARK: - Native Text
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
@@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(selection)
}
///
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
/// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
@@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
///
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let fragmentID = nearestFragmentID(
beforeOrAt: startOffset,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
)
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: resolvedPage.page.href,
progression: Double(startOffset) / Double(chapterLength),
lastProgression: Double(endOffset) / Double(chapterLength),
fragment: fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(
start: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: startOffset,
fragmentID: fragmentID
),
end: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: endOffset,
fragmentID: fragmentID
)
)
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(
@@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return location
}
/// spine
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
@@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
/// +
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
@@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
@@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return bestID
}
// MARK: -
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
@@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
let sourceLocation = currentVisibleLocation()
let sourceCFI = sourceLocation?.cfi
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
guard let note = resolver.resolveInternalLink(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetLocation: location,
sourceLocation: sourceLocation,
relativeToSpineIndex: fromSpineIndex
) else {
return false
}
RDEPUBNotePopupCoordinator.present(
note,
from: self,
onReturnToSource: { [weak self] in
guard let self, let sourceLocation = note.sourceLocation else { return }
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
},
onOpenNoteLocation: { [weak self] in
guard let self else { return }
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
}
)
return true
}
private func navigateToLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int?,
animated: Bool
) {
guard let pageNumber = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
@@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
}
private extension RDEPUBReaderController {
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])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
@@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView {
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
@@ -1,35 +1,34 @@
// RDEPUBReaderController+DataSource.swift
// EPUB
// RDReaderDataSource RDReaderDelegate
//
import UIKit
/// RDEPUBReaderController
///
/// RDReaderDataSource RDReaderDelegate
///
// MARK: - RDReaderView
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
///
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
}
/// 退 Web
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: resolvedPage.page,
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration,
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
highlights: textHighlights(for: resolvedPage.page),
searchState: searchState(for: resolvedPage.page)
)
@@ -40,11 +39,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: page,
pageNumber: pageNum + 1,
totalPages: textBook.pages.count,
configuration: configuration,
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
highlights: textHighlights(for: page),
searchState: searchState(for: page)
)
@@ -68,14 +78,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
return contentView
}
/// Web
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
(textBook == nil && readerContext.bookPageMap == nil)
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
///
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
@@ -91,9 +99,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
}
///
/// currentMatchIndex
/// href
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
@@ -237,6 +242,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
@@ -274,31 +280,25 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
return publication.resourceResolver.normalizedHref(href) ?? href
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
}
///
public func topToolView(readerView: RDReaderView) -> UIView? {
topToolView
}
///
public func bottomToolView(readerView: RDReaderView) -> UIView? {
bottomToolView
}
///
public func pageNum(readerView: RDReaderView, pageNum: Int) {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
//
guard !isRepaginating else { return }
// map
let previousCurrentPage = readerView.currentPage
runtime.applyPendingFullPageMapIfNeeded()
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
@@ -320,7 +320,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
return
}
@@ -332,17 +332,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
readingSession?.transition(to: .idle)
}
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
}
///
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
}
///
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating,
@@ -365,12 +362,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
isReconcilingTextPaginationSize = true
// 使
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
//
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
guard currentSize.width > 0, currentSize.height > 0 else { return }
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
@@ -1,54 +1,34 @@
// RDEPUBReaderController+PublicAPI.swift
// RDEPUBReaderController API
import UIKit
// MARK: - Public Reader Commands
extension RDEPUBReaderController {
/// EPUB
public func reloadBook() {
runtime.reloadBook()
}
///
/// - Parameter location:
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
_ = runtime.go(to: location)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
runtime.go(toPageNumber: pageNumber, animated: animated)
}
///
public func clearSelection() {
runtime.clearSelection()
}
///
/// - Parameter id: ID
/// - Returns: nil
public func bookmark(withID id: String) -> RDEPUBBookmark? {
runtime.bookmark(withID: id)
}
///
/// - Parameter id: ID
/// - Returns: nil
public func highlight(withID id: String) -> RDEPUBHighlight? {
runtime.highlight(withID: id)
}
/// 访
/// - Returns: nil
public func nativeTextSemanticSummary() -> String? {
let resolvedPage: RDEPUBTextPage?
if let textBook {
@@ -79,12 +59,6 @@ extension RDEPUBReaderController {
return parts.joined(separator: " · ")
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
@@ -94,13 +68,6 @@ extension RDEPUBReaderController {
runtime.addHighlight(from: selection, color: color, note: note)
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - style: 线
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addAnnotation(
from selection: RDEPUBSelection? = nil,
@@ -111,72 +78,40 @@ extension RDEPUBReaderController {
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
}
///
/// - Parameter highlight:
/// - Returns:
@discardableResult
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
runtime.upsertHighlight(highlight)
}
///
/// - Parameter id: ID
/// - Returns: nil
@discardableResult
public func removeHighlight(id: String) -> RDEPUBHighlight? {
runtime.removeHighlight(id: id)
}
///
/// - Parameters:
/// - id: ID
/// - note: nil
/// - Returns: nil
@discardableResult
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
runtime.updateHighlightNote(id: id, note: note)
}
///
/// - Parameters:
/// - id: ID
/// - animated:
/// - Returns:
@discardableResult
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
runtime.go(toHighlightID: id, animated: animated)
}
///
public func removeAllHighlights() {
runtime.removeAllHighlights()
}
///
/// - Parameters:
/// - item: EPUB
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
///
/// - Parameters:
/// - item: RDReader
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
/// href href
/// - Parameters:
/// - href: #fragment
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
guard publication != nil else { return false }
@@ -193,61 +128,40 @@ extension RDEPUBReaderController {
return restoreReadingLocation(location, animated: animated)
}
///
/// - Parameter note:
/// - Returns: nil
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.addBookmark(note: note)
}
///
/// - Parameter note:
/// - Returns:
@discardableResult
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.toggleBookmark(note: note)
}
///
/// - Parameter id: ID
/// - Returns: nil
@discardableResult
public func removeBookmark(id: String) -> RDEPUBBookmark? {
runtime.removeBookmark(id: id)
}
///
/// - Parameters:
/// - id: ID
/// - animated:
/// - Returns:
@discardableResult
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
runtime.go(toBookmarkID: id, animated: animated)
}
///
/// - Parameter keyword:
public func search(keyword: String) {
runtime.search(keyword: keyword)
}
///
/// - Returns:
@discardableResult
public func searchNext() -> Bool {
runtime.searchNext()
}
///
/// - Returns:
@discardableResult
public func searchPrevious() -> Bool {
runtime.searchPrevious()
}
///
public func clearSearch() {
runtime.clearSearch()
}
@@ -1,47 +1,32 @@
// RDEPUBReaderController+RenderSupport.swift
// EPUB
//
// 宿退
import UIKit
/// RDEPUBReaderController
///
///
/// 退
extension RDEPUBReaderController {
///
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
readerContext.currentLayoutContext()
}
///
func currentPreferences() -> RDEPUBPreferences {
readerContext.currentPreferences()
}
///
func currentTextPageSize() -> CGSize {
readerContext.currentTextPageSize()
}
///
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
readerContext.currentTextRenderStyle()
}
///
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
readerContext.currentTextLayoutConfig(pageSize: pageSize)
}
///
func resolvedTextRenderer() -> RDEPUBTextRenderer {
readerContext.resolvedTextRenderer()
}
/// 宿
func ensurePaginationHostView() -> UIView {
let viewportSize = currentLayoutContext().viewportSize
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
@@ -53,7 +38,6 @@ extension RDEPUBReaderController {
return paginationHostView
}
///
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
guard let publication, activePages.indices.contains(pageIndex) else {
return nil
@@ -75,13 +59,11 @@ extension RDEPUBReaderController {
)
}
/// 退使
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
guard activePages.indices.contains(pageIndex) else { return nil }
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
}
///
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
guard let publication else { return [] }
if let spread = page.fixedSpread {
@@ -1,17 +1,8 @@
// RDEPUBReaderController+RuntimeBridge.swift
// EPUB
// runtime
//
import UIKit
/// RDEPUBReaderController
///
/// runtime
///
extension RDEPUBReaderController {
/// //
func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
@@ -32,17 +23,14 @@ extension RDEPUBReaderController {
}
}
///
func startInitialLoadIfNeeded() {
runtime.startInitialLoadIfNeeded()
}
/// EPUB
func loadPublication() {
runtime.loadPublication()
}
///
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -61,17 +49,14 @@ extension RDEPUBReaderController {
)
}
///
func paginatePublication(restoreLocation: RDEPUBLocation?) {
runtime.paginatePublication(restoreLocation: restoreLocation)
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
}
///
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -79,17 +64,14 @@ extension RDEPUBReaderController {
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
runtime.finishPagination(restoreLocation: restoreLocation)
}
///
func repaginatePreservingCurrentLocation() {
runtime.repaginatePreservingCurrentLocation()
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -103,27 +85,22 @@ extension RDEPUBReaderController {
)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
runtime.currentVisibleLocation()
}
///
func persistenceLocation() -> RDEPUBLocation? {
readerContext.persistenceLocation()
}
///
func persist(location: RDEPUBLocation) {
readerContext.persist(location: location)
}
///
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
runtime.annotationCoordinator.updateCurrentSelection(selection)
}
/// spine
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
@@ -131,60 +108,49 @@ extension RDEPUBReaderController {
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
}
///
func refreshVisibleContentPreservingLocation() {
runtime.refreshVisibleContentPreservingLocation()
}
///
func rebuildExternalTextBook() {
runtime.rebuildExternalTextBook()
}
/// UI
func updateReaderChrome() {
runtime.updateReaderChrome()
}
///
func presentBookmarksManager() {
runtime.presentBookmarksManager()
}
///
func presentHighlightsManager() {
runtime.presentHighlightsManager()
}
///
func presentAnnotationCreation() {
runtime.presentAnnotationCreation()
}
///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
runtime.handleSelectionMenuAction(action, selection: selection)
}
///
func presentSettings() {
runtime.presentSettings()
}
/// 使
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
var nextConfiguration = configuration
update(&nextConfiguration)
configuration = nextConfiguration
}
///
func setScreenBrightness(_ brightness: CGFloat) {
currentBrightness = max(0, min(1, brightness))
persistReaderSettingsIfNeeded()
}
///
func persistReaderSettingsIfNeeded() {
let settings = RDEPUBReaderSettings.capture(
configuration: configuration,
@@ -193,7 +159,6 @@ extension RDEPUBReaderController {
persistence?.saveReaderSettings(settings)
}
///
func requiresRepagination(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
@@ -210,7 +175,6 @@ extension RDEPUBReaderController {
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
}
///
func requiresVisibleRefresh(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
@@ -220,17 +184,14 @@ extension RDEPUBReaderController {
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
}
///
func presentTableOfContents() {
runtime.presentTableOfContents()
}
///
func handleBackAction() {
runtime.handleBackAction()
}
///
func handle(error: Error) {
isRepaginating = false
hideLoading()
@@ -243,23 +204,19 @@ extension RDEPUBReaderController {
delegate?.epubReader(self, didFailWithError: error)
}
///
func showLoading() {
errorLabel.isHidden = true
loadingIndicator.startAnimating()
}
///
func hideLoading() {
loadingIndicator.stopAnimating()
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
runtime.currentViewportSignature()
}
///
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -267,21 +224,17 @@ extension RDEPUBReaderController {
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
///
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
runtime.searchPresentation(for: page)
}
///
private func resolvedPageDirection() -> RDReaderView.PageDirection {
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
}
}
// MARK: -
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
/// true
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
@@ -1,17 +1,8 @@
// RDEPUBReaderController+TableOfContents.swift
// EPUB
// Table of Contents
// 线
import Foundation
/// RDEPUBReaderController
///
/// Table of Contents
/// 线
extension RDEPUBReaderController {
/// href
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContentsItems(
from: publication?.tableOfContents ?? [],
@@ -51,7 +42,6 @@ extension RDEPUBReaderController {
}
}
/// 线
func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0,
@@ -1,32 +1,9 @@
import UIKit
// MARK: -
/// EPUB
/// EPUBUI
/// EPUB URL TextBook
///
/// **** EPUBUI Core Parser Foundation
///
/// ****
/// - EPUB
/// -
/// -
/// - UI
/// -
///
/// **** init(epubURL:) viewDidLoad RDEPUBParser.parse paginatePublication readerView.reloadData restoreLocation
// MARK: -
public final class RDEPUBReaderController: UIViewController {
// MARK: -
///
public weak var delegate: RDEPUBReaderDelegate?
///
public var configuration: RDEPUBReaderConfiguration {
didSet {
readerContext.configuration = configuration
@@ -54,65 +31,58 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
/// 1 nil
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
/// /
public internal(set) var currentSelection: RDEPUBSelection? {
get { readerContext.currentSelection }
set { readerContext.currentSelection = newValue }
}
///
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
///
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
/// WXRead WRBookmark
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
///
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
///
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
// MARK: -
/// EPUB URL
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -121,97 +91,115 @@ public final class RDEPUBReaderController: UIViewController {
label.isHidden = true
return label
}()
let paginationHostView = UIView()
lazy var readerContext = RDEPUBReaderContext(controller: self)
var parser: RDEPUBParser? {
get { readerContext.parser }
set { readerContext.parser = newValue }
}
var publication: RDEPUBPublication? {
get { readerContext.publication }
set { readerContext.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { readerContext.readingSession }
set { readerContext.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { readerContext.textBook }
set { readerContext.textBook = newValue }
}
var activePages: [EPUBPage] {
readerContext.activePages
}
var activeChapters: [EPUBChapterInfo] {
readerContext.activeChapters
}
var activeBookmarks: [RDEPUBBookmark] {
get { readerContext.activeBookmarks }
set { readerContext.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { readerContext.activeHighlights }
set { readerContext.activeHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
///
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
}
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
var currentBrightness: CGFloat {
get { readerContext.currentBrightness }
set { readerContext.currentBrightness = newValue }
}
var didStartInitialLoad: Bool {
get { readerContext.didStartInitialLoad }
set { readerContext.didStartInitialLoad = newValue }
}
var isRepaginating: Bool {
get { readerContext.isRepaginating }
set { readerContext.isRepaginating = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { readerContext.lastTextPaginationPageSize }
set { readerContext.lastTextPaginationPageSize = newValue }
}
var isReconcilingTextPaginationSize = false
var paginationToken: UUID {
get { readerContext.paginationToken }
set { readerContext.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { readerContext.paginator }
set { readerContext.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
}
var isExternalTextBook: Bool {
get { readerContext.isExternalTextBook }
set { readerContext.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { readerContext.textFileURL }
set { readerContext.textFileURL = newValue }
}
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
// MARK: -
/// 使 EPUB URL
///
/// - Parameters:
/// - epubURL: EPUB URL
/// - configuration: 使 .default
/// - persistence: 使 UserDefaults
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
@@ -225,7 +213,7 @@ public final class RDEPUBReaderController: UIViewController {
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
// Sync context after super.init
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
@@ -245,15 +233,6 @@ public final class RDEPUBReaderController: UIViewController {
)
}
/// 使 TextBook EPUB
/// TXT TextBook
/// - Parameters:
/// - textBook: TextBook
/// - bookIdentifier:
/// - title:
/// - textFileURL: URL TextBook
/// - configuration:
/// - persistence:
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
@@ -317,10 +296,6 @@ public final class RDEPUBReaderController: UIViewController {
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: -
///
/// `handleToolViewVisibilityChanged`
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
@@ -367,7 +342,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
/// readerView
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
@@ -403,8 +377,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
/// - Parameter clearSearch:
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
@@ -425,7 +397,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
@@ -438,7 +409,6 @@ public final class RDEPUBReaderController: UIViewController {
)
}
/// RDReaderView
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
@@ -456,6 +426,7 @@ public final class RDEPUBReaderController: UIViewController {
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
@@ -1,93 +1,34 @@
import UIKit
// MARK: -
/// EPUB
///
///
public protocol RDEPUBReaderDelegate: AnyObject {
///
/// - Parameters:
/// - reader:
/// - publication:
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
///
/// - Parameters:
/// - reader:
/// - location:
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
///
/// - Parameter reader:
func epubReaderDidReachEnd(_ reader: UIViewController)
///
/// - Parameters:
/// - reader:
/// - selection: nil
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
///
/// - Parameters:
/// - reader:
/// - highlights:
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
///
/// - Parameters:
/// - reader:
/// - bookmarks:
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
///
/// - Parameters:
/// - reader:
/// - result: nil
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
///
/// - Parameters:
/// - reader:
/// - match: nil
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
///
/// - Parameters:
/// - reader:
/// - item:
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
///
/// - Parameters:
/// - reader:
/// - url: URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// false
/// - Parameters:
/// - reader:
/// - url: URL
/// - Returns:
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
///
/// - Parameters:
/// - reader:
/// - error:
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
///
/// viewDidLoad
/// - Parameters:
/// - reader:
/// - topToolView:
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
// MARK: -
///
public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
@@ -1,24 +1,19 @@
import UIKit
// MARK: -
/// EPUB
/// //
final class RDEPUBReaderHighlightsViewController: UITableViewController {
///
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
///
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
///
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
///
private var highlights: [RDEPUBHighlight]
///
private let theme: RDEPUBReaderTheme
///
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
///
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
private var filteredHighlights: [RDEPUBHighlight] {
@@ -50,6 +45,8 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.highlights.panel"
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
configureFilterControl()
@@ -87,6 +84,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func configureFilterControl() {
filterControl.selectedSegmentIndex = 0
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
navigationItem.titleView = filterControl
}
@@ -96,6 +94,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
}
private func updateEmptyState() {
@@ -109,6 +108,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
}
@@ -216,18 +216,16 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
}
}
// MARK: -
/// EPUB
///
final class RDEPUBReaderBookmarksViewController: UITableViewController {
///
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
///
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
private var bookmarks: [RDEPUBBookmark]
private let theme: RDEPUBReaderTheme
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
@@ -298,6 +296,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
tableView.backgroundView = label
return
}
@@ -1,45 +1,24 @@
import Foundation
// MARK: -
///
///
///
public protocol RDEPUBReaderPersistence: AnyObject {
///
/// - Parameter bookIdentifier:
/// - Returns: nil
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
///
/// - Parameters:
/// - location:
/// - bookIdentifier:
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
///
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
///
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
///
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
///
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
///
func loadReaderSettings() -> RDEPUBReaderSettings?
///
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
}
// MARK: -
/// //
/// DEBUG no-op 便
public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
#if DEBUG
@@ -68,30 +47,18 @@ public extension RDEPUBReaderPersistence {
}
}
// MARK: - UserDefaults
/// UserDefaults
/// JSON
/// SQLite
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
/// UserDefaults
private let defaults: UserDefaults
/// key
private let locationPrefix: String
/// key
private let bookmarksPrefix: String
/// key
private let highlightsPrefix: String
/// key
private let settingsKey: String
/// UserDefaults
/// - Parameters:
/// - defaults: UserDefaults .standard
/// - locationPrefix: key
/// - bookmarksPrefix: key
/// - highlightsPrefix: key
/// - settingsKey: key
public init(
defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.",
@@ -1,26 +1,33 @@
import UIKit
struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
let title: String
let items: [Item]
}
// MARK: -
///
///
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
var onSearchSubmit: ((String) -> Void)?
var onSearchTextChanged: ((String) -> Void)?
var onSearchPrevious: (() -> Void)?
var onSearchNext: (() -> Void)?
var onSelectMatch: ((Int) -> Void)?
var onClose: (() -> Void)?
let textField: UITextField = {
@@ -39,23 +46,35 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}()
private let backgroundButton = UIButton(type: .custom)
private let panelView = UIView()
private let grabberView = UIView()
private let searchRowView = UIView()
private let searchFieldContainer = UIView()
private let searchIcon = UIImageView()
private let searchFieldDivider = UIView()
private let cancelButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyStateLabel = UILabel()
// Preserve legacy accessibility hooks used by existing tests and demo logic.
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel()
private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = ""
private var currentMatchIndex: Int?
override init(frame: CGRect) {
@@ -221,7 +240,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
// Legacy shims
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
@@ -405,6 +423,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}
private extension UIColor {
var rd_searchIsDarkBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -419,6 +438,7 @@ private extension UIColor {
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
@@ -485,6 +505,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
@@ -494,15 +515,21 @@ extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
}
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
static var highlightTextColor = UIColor.systemBlue
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
private let cardView = UIView()
private let previewLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@@ -1,17 +1,13 @@
import Foundation
// MARK: -
///
/// depth
public struct RDEPUBReaderTableOfContentsItem: Equatable {
///
public var title: String
/// href EPUB
public var href: String
/// 0
public var depth: Int
/// 1 nil
public var pageNumber: Int?
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
@@ -1,13 +1,9 @@
import UIKit
// MARK: -
///
/// 线
open class RDEPUBReaderToolView: UIView {
/// 线
private let lineView = UIView()
/// 线
private let lineHeight: CGFloat = 0.5
override init(frame: CGRect) {
@@ -25,24 +21,15 @@ open class RDEPUBReaderToolView: UIView {
lineView.frame = lineFrame(in: bounds)
}
///
/// - Parameter theme:
open func apply(theme: RDEPUBReaderTheme) {
backgroundColor = .white
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
}
/// 线线
/// - Parameter bounds: bounds
/// - Returns: 线 frame
open func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
}
}
// MARK: -
///
/// UIButton
final class RDEPUBReaderTintButton: UIButton {
}
@@ -1,16 +1,11 @@
import UIKit
// MARK: -
///
///
/// iOS 13+ SF Symbols 退
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
///
var onBack: (() -> Void)?
///
var onToggleBookmark: (() -> Void)?
///
var onSearch: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)
@@ -1,19 +1,17 @@
import UIKit
///
///
struct RDEPUBViewportSignature: Equatable {
/// pt
let width: CGFloat
/// pt
let height: CGFloat
///
let safeTop: CGFloat
///
let safeLeft: CGFloat
///
let safeBottom: CGFloat
///
let safeRight: CGFloat
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
@@ -26,13 +24,11 @@ struct RDEPUBViewportSignature: Equatable {
}
}
///
enum RDEPUBViewportChangeReason {
/// Safe Area
case viewLayout
///
case orientationTransition
}
///
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
@@ -1,29 +1,20 @@
import UIKit
// MARK: - Web
/// Web
/// WebView
protocol RDEPUBWebContentViewDelegate: AnyObject {
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
/// WebView JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
}
// MARK: - Web
/// EPUB Web
/// RDEPUBWebView/
/// EPUB Native Text
final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate?
@@ -1,8 +1,5 @@
import UIKit
/// EPUB WebView
/// WebView 线
/// WXRead JS rect CGContext
final class RDEPUBWebDecorationOverlayView: UIView {
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let verticalAdjustment: CGFloat = -1
@@ -18,8 +15,6 @@ final class RDEPUBWebDecorationOverlayView: UIView {
fatalError("init(coder:) has not been implemented")
}
///
/// - Parameter decorations: 线
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations.filter { !$0.rects.isEmpty }
setNeedsDisplay()
@@ -1,29 +1,6 @@
//
// RDURLReaderController.swift
// RDReaderDemo
//
// URL
// ReadViewSDK 使
// - .epub RDEPUBReaderController
// - RDPlainTextBookBuilder RDEPUBReaderController
// - 退 UITextView
//
// RDReaderView UI
//
import UIKit
/// URL
/// URL
/// - EPUB 使 RDEPUBReaderController
/// - RDPlainTextBookBuilder 使 RDEPUBReaderController
/// - 退 UITextView
///
/// 使
/// ```swift
/// let controller = RDURLReaderController(bookURL: fileURL)
/// navigationController?.pushViewController(controller, animated: true)
/// ```
public final class RDURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
@@ -31,11 +8,10 @@ public final class RDURLReaderController: UIViewController {
var attemptCount: Int
}
/// URL
private let bookURL: URL
/// EPUB
private let epubConfiguration: RDEPUBReaderConfiguration
///
private var embeddedController: UIViewController?
private let demoStateLabel: UILabel = {
let label = UILabel()
@@ -58,10 +34,6 @@ public final class RDURLReaderController: UIViewController {
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
///
/// - Parameters:
/// - bookURL: URL
/// - epubConfiguration: EPUB 使
public init(
bookURL: URL,
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
@@ -76,7 +48,6 @@ public final class RDURLReaderController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
///
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
@@ -101,18 +72,11 @@ public final class RDURLReaderController: UIViewController {
stopDemoStateTimer()
}
/// Demo
/// - Parameter displayType:
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
readerController?.configuration.displayType = displayType
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
}
/// Demo
/// - Parameters:
/// - pageNumber:
/// - animated:
/// - Returns:
@discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = performDemoPageNavigation(pageNumber, animated: animated)
@@ -125,12 +89,6 @@ public final class RDURLReaderController: UIViewController {
return moved
}
/// Demo
///
/// - Parameters:
/// - displayTypes:
/// - initialPageNumber:
/// - stepDelay:
public func runDemoDisplaySequence(
_ displayTypes: [RDReaderView.DisplayType],
initialPageNumber: Int? = nil,
@@ -151,9 +109,6 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
/// `didUpdateLocation`
/// - Parameter keyword:
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
@@ -187,8 +142,6 @@ public final class RDURLReaderController: UIViewController {
submitSearchAfterDelay(keyword: keyword, retries: 5)
}
///
/// Child View Controller
private func embedReaderController() {
let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" {
@@ -224,7 +177,7 @@ public final class RDURLReaderController: UIViewController {
configuration: epubConfiguration
)
} else {
// 退
let fallback = UIViewController()
let textView = UITextView()
textView.isEditable = false
@@ -248,7 +201,6 @@ public final class RDURLReaderController: UIViewController {
emitDemoState()
}
/// EPUB
private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
@@ -322,7 +274,6 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
private func logDemoState(prefix: String) {
guard let readerController else { return }
let location = readerController.currentLocation
@@ -372,6 +323,9 @@ public final class RDURLReaderController: UIViewController {
let location = readerController?.currentLocation
let href = encodedDemoLocationHref(location?.href)
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let cfi = encodedDemoField(location?.cfi)
let lastCFI = encodedDemoField(location?.lastCFI)
let rangeCFI = encodedDemoField(location?.rangeCFI)
let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
@@ -388,6 +342,9 @@ public final class RDURLReaderController: UIViewController {
"selection=\(selection)",
"href=\(href)",
"progression=\(progression)",
"cfi=\(cfi)",
"lastCFI=\(lastCFI)",
"rangeCFI=\(rangeCFI)",
"mode=\(mapSnapshot.mode)",
"pagination=\(mapSnapshot.phase)",
"knownPages=\(mapSnapshot.knownPages)",
@@ -464,14 +421,10 @@ public final class RDURLReaderController: UIViewController {
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
}
///
/// 使 viewport layoutConfig.edgeInsets
///
private func currentTextPageSize() -> CGSize {
UIScreen.main.bounds.size
}
///
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
@@ -483,8 +436,6 @@ public final class RDURLReaderController: UIViewController {
)
}
///
/// UTF-8 GBK GB2312
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
@@ -536,9 +487,8 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
}
}
/// Demo
private extension RDReaderView.DisplayType {
/// Demo
var demoArgumentValue: String {
switch self {
case .pageCurl:
@@ -1,22 +1,30 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let threadName = resolvedThreadName()
let queueLabel = resolvedQueueLabel()
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
}
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
let startedAt = CFAbsoluteTimeGetCurrent()
log(scope, "START \(message)")
do {
let result = try work()
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
return result
} catch {
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
throw error
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
}
private static func resolvedThreadName() -> String {
if let name = Thread.current.name, !name.isEmpty {
return name
}
if Thread.isMainThread {
return "main"
}
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
}
@@ -1,29 +1,26 @@
import Foundation
/// BookPageMap
/// NSAttributedString 100
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
///
let pageCount: Int
/// 0
let absolutePageStart: Int
/// fragment ID
let fragmentOffsets: [String: Int]
}
///
/// 100 /1000 100KB
///
/// spineIndex
///
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
/// spineIndex
private let indexBySpine: [Int: Int] // spineIndex -> entries
///
private let indexBySpine: [Int: Int]
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
@@ -38,9 +35,6 @@ struct RDEPUBBookPageMap {
static let empty = RDEPUBBookPageMap(entries: [])
// MARK: -
/// spineIndex +
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
@@ -48,10 +42,9 @@ struct RDEPUBBookPageMap {
return entry.absolutePageStart + localPageIndex
}
/// spineIndex
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
// entries absolutePageStart
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
@@ -65,7 +58,6 @@ struct RDEPUBBookPageMap {
return entries[lo - 1].spineIndex
}
///
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
@@ -75,29 +67,23 @@ struct RDEPUBBookPageMap {
return local
}
/// spineIndex
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
/// spineIndex entries
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
/// spineIndex
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
///
var totalChapters: Int { entries.count }
// MARK: -
/// Builder pageCount BookPageMap
struct Builder {
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
@@ -105,7 +91,7 @@ struct RDEPUBBookPageMap {
}
func build() -> RDEPUBBookPageMap {
// spineIndex
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
@@ -1,8 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private let lock = NSLock()
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterLoader {
private unowned let context: RDEPUBReaderContext
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
init(context: RDEPUBReaderContext) {
@@ -12,24 +14,22 @@ final class RDEPUBChapterLoader {
summaryDiskCache = cache
}
// MARK: -
enum LoadPriority {
case navigation //
case preview //
case prefetch // ±1 +
case navigation
case preview
case prefetch
}
// MARK: -
/// chapterLoadQueue 线
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
// 1. 线 completion 线
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
DispatchQueue.main.async {
@@ -38,14 +38,12 @@ final class RDEPUBChapterLoader {
return
}
// 2-3. + pageCountCache
// contentHashForSpineIndex SHA256 + I/O 线
store.markBuilding(true)
store.chapterLoadQueue.async {
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
// pageCountCache
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@@ -63,6 +61,7 @@ final class RDEPUBChapterLoader {
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
do {
let chapter = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
@@ -75,7 +74,6 @@ final class RDEPUBChapterLoader {
}
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
// 5.
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
@@ -86,13 +84,12 @@ final class RDEPUBChapterLoader {
)
store.insertPageCount(pc, for: cacheKey)
// 6.
switch priority {
case .navigation:
// §14.4
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
//
store.markBuilding(false)
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
return
@@ -109,8 +106,7 @@ final class RDEPUBChapterLoader {
}
case .prefetch:
//
//
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
@@ -118,6 +114,7 @@ final class RDEPUBChapterLoader {
}
}
} catch {
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
store.markBuilding(false)
DispatchQueue.main.async {
@@ -127,14 +124,6 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - legacy 使
///
/// - chapterLoadQueue WXRead
/// - RDEPUBTextBook
/// - 线 chapterLoadQueue
/// - store.assertNotOnChapterLoadQueue()
/// - 使
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
@@ -193,8 +182,6 @@ final class RDEPUBChapterLoader {
return try result!.get()
}
// MARK: -
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
@@ -210,7 +197,7 @@ final class RDEPUBChapterLoader {
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
if let pageRanges = availablePageRanges {
// ---- pageCountCache chapterSummaryDiskCache ----
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
@@ -224,7 +211,6 @@ final class RDEPUBChapterLoader {
)
}
// ---- + ----
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
@@ -245,8 +231,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - pageRanges
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
@@ -259,14 +243,14 @@ final class RDEPUBChapterLoader {
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title ?? ""
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
// 1. HTML NSAttributedString
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: try requireHTMLString(parser, href: href),
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
@@ -281,14 +265,24 @@ final class RDEPUBChapterLoader {
in: typesetString, style: style, layoutConfig: layoutConfig
)
// 2. metadata
// - diskSummary metadata
// - diskSummary pageCountCache attributedString
let metadataSource = diskSummary?.pageMetadataList
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
let effectivePageRanges: [NSRange]
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
if sanitizedCachedRanges.count == pageRanges.count {
effectivePageRanges = sanitizedCachedRanges
metadataSource = diskSummary?.pageMetadataList
} else {
RDEPUBBackgroundTrace.log(
"ChapterLoader",
"缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)"
)
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
// 3. pageRanges pages CoreText
let pages = buildPagesFromRanges(
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
@@ -296,35 +290,39 @@ final class RDEPUBChapterLoader {
metadataSource: metadataSource
)
// 4. layouter
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
// 5. chapterOffsetMap
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset }
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: rendered.fragmentOffsets,
rawHTML: rawHTML,
chapterText: typesetString.string
),
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil, // source
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
// MARK: - pageRanges RDEPUBTextPage
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
@@ -338,10 +336,10 @@ final class RDEPUBChapterLoader {
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
// metadata
metadata = metaList[pageIndex].toPageMetadata()
} else {
// metadata attributedString
metadata = inferPageMetadata(
from: typesetString,
range: range,
@@ -366,7 +364,21 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - attributedString metadata
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
guard contentLength > 0 else { return [] }
return pageRanges.compactMap { range in
guard range.location >= 0, range.location < contentLength else {
return nil
}
let maxLength = contentLength - range.location
let clampedLength = min(max(range.length, 0), maxLength)
guard clampedLength > 0 else {
return nil
}
return NSRange(location: range.location, length: clampedLength)
}
}
private func inferPageMetadata(
from string: NSAttributedString,
@@ -431,8 +443,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - RDEPUBRuntimeChapter
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
@@ -448,17 +458,25 @@ final class RDEPUBChapterLoader {
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap ?? makeCFIMap(
href: chapter.href,
spineIndex: spineIndex,
fragmentOffsets: chapter.fragmentOffsets,
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
chapterText: chapter.attributedContent.string
),
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
// P2
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -479,13 +497,10 @@ final class RDEPUBChapterLoader {
)
}
// MARK: -
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
// renderSignature §8.2
let lineHeightMultiple = context.configuration.lineHeightMultiple
let renderSignature = [
@@ -521,11 +536,57 @@ final class RDEPUBChapterLoader {
}
return html
}
private func makeCFIMap(
href: String,
spineIndex: Int,
fragmentOffsets: [String: Int],
rawHTML: String?,
chapterText: String
) -> RDEPUBCFIMap {
if let rawHTML {
return RDEPUBCFITextNodeMapBuilder.makeMap(
href: href,
rawHTML: rawHTML,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets
)
}
let domPaths: [String: RDEPUBCFIPath] = [:]
let markers = fragmentOffsets
.sorted { $0.value < $1.value }
.map { fragmentID, offset in
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: href,
fileIndex: spineIndex,
chapterOffset: offset,
fragmentID: fragmentID
)
return RDEPUBCFIMarker(
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
chapterOffset: offset,
fragmentID: fragmentID
)
}
return RDEPUBCFIMap(
href: href,
markers: markers,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: "",
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
@@ -1,17 +1,15 @@
import Foundation
///
/// RDEPUBLocation progression
public struct RDEPUBChapterLocation: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0-based
public var chapterOffset: Int
/// HTML fragment ID #section1
public var fragmentID: String?
/// progressionfragmentID nil
public var progressionInChapter: Double?
/// schema 1=, 2=
public var schemaVersion: Int
public init(
@@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable {
self.schemaVersion = schemaVersion
}
/// schemaVersion == 1 chapterOffset progression
var isFallbackEstimate: Bool { schemaVersion == 1 }
}
@@ -1,16 +1,35 @@
import Foundation
struct RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
/// fragmentID ->
let cfiMap: RDEPUBCFIMap?
let chapterText: String?
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
/// -> 0
func chapterOffset(forCFI rawCFI: String?) -> Int? {
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
let resolved = RDEPUBCFIResolver.resolve(cfi)
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
return RDEPUBCFIRecoveryEngine.recover(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
)?.chapterOffset
}
func pageIndex(forChapterOffset offset: Int) -> Int? {
for i in 0..<pageStartOffsets.count {
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
@@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap {
}
return nil
}
}
}
@@ -2,50 +2,36 @@ import UIKit
final class RDEPUBChapterRuntimeStore {
// MARK: -
/// WXRead chapterDataCache
private let chapterDataCache = RDEPUBChapterDataCache()
/// WXRead pageCountCache
private let pageCountCache = RDEPUBPageCountCache()
/// NSCache WXRead imageCache
let imageCache = NSCache<NSString, UIImage>()
/// WXRead com.weread.chapterload
/// QoS .userInitiated/
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// MARK: -
/// spineIndex
private(set) var currentSpineIndex: Int?
/// spineIndex
private(set) var windowSpineIndices: [Int] = []
// MARK: - vs
/// ///
///
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
/// ±1
///
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
///
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
// MARK: -
init() {
imageCache.countLimit = 50
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
@@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
// MARK: - 线 cache wrapper lock
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
@@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore {
return pageCountCache[key]
}
// MARK: -
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
}
@@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore {
pageCountCache[key] = pc
}
// MARK: -
///
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
@@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore {
windowSpineIndices = Array(lowerBound...upperBound)
}
/// spineIndex
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
// MARK: -
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
@@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore {
if let ch = currentChapter {
chapterDataCache[current] = ch
}
// WXRead pageCountCache
pageCountCache.removeAll()
}
// MARK: -
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
// MARK: - §14.4
///
///
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
///
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
@@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore {
return target
}
// MARK: -
/// ±1
///
func addPrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.insert(spineIndex)
prefetchLock.unlock()
}
///
func removePrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.remove(spineIndex)
prefetchLock.unlock()
}
///
func clearPrefetchTargets() {
prefetchLock.lock()
pendingPrefetchTargets.removeAll()
prefetchLock.unlock()
}
///
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
prefetchLock.lock()
let has = pendingPrefetchTargets.contains(spineIndex)
@@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore {
buildingLock.unlock()
}
// MARK: - P1: §8.5
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
@@ -1,8 +1,11 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
@@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: -
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.async {
self.writeImmediately(summary: summary, for: key)
}
}
///
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.sync {
self.writeImmediately(summary: summary, for: key)
}
}
///
func flushPendingWrites() {
queue.sync { }
}
// MARK: - loadChapter
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
let data: Data
@@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache {
} catch {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
//
} else {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
@@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache {
}
}
// MARK: - BookPageMap
/// spineIndex summary
/// 线
/// contentHash
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
@@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache {
return (summaries, mapBuilder)
}
///
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
@@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache {
return true
}
///
func removeAll() {
removeFiles(matching: { _ in true })
}
///
func removeAll(forBookID bookID: String) {
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
removeFiles { $0.hasPrefix(bookPrefix + "__") }
}
///
func removeAll(forRenderSignature renderSignature: String) {
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
removeFiles { $0.contains(renderPrefix) }
}
///
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return (0, 0)
@@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache {
return (count, totalBytes)
}
// MARK: - key ->
/// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
@@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache {
}
struct RDEPUBChapterSummary: Codable {
let pageRanges: [RangeData]
let pageCount: Int
let fragmentOffsets: [String: Int]
let cfiMap: RDEPUBCFIMap?
let renderSignature: String
let schemaVersion: Int
let chapterContentHash: String
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 6
static let currentSchemaVersion = 9
struct RangeData: Codable {
let location: Int
let length: Int
var nsRange: NSRange { NSRange(location: location, length: length) }
}
struct PageMetadataSummary: Codable {
let breakReason: String
let attachmentRanges: [RangeData]
let attachmentKinds: [String]
let blockKinds: [String]
let semanticHints: [String]
let attachmentPlacements: [String]
let trailingFragmentID: String?
func toPageMetadata() -> RDEPUBTextPageMetadata {
@@ -1,14 +1,15 @@
import Foundation
final class RDEPUBChapterWindowCoordinator {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
private let loader: RDEPUBChapterLoader
///
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
///
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
@@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator {
self.loader = loader
}
///
private var restoreChapterOffset: Int?
// MARK: -
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
@@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator {
)
self.restoreChapterOffset = restoreChapterOffset
//
isSwitchingChapter = true
//
store.setNavigationTarget(spineIndex: targetSpineIndex)
//
store.clearPrefetchTargets()
//
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
}
///
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
@@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator {
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(
@@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator {
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
//
self.isSwitchingChapter = false
self.handle(error: error)
}
@@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
#if DEBUG
@@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator {
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
// chapterOffset
if let offset = restoreChapterOffset,
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
let targetPage = snapshot.anchorPageOffset + pageIndex
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
} else if snapshot.pageCount > 0 {
//
// reloadData() switchReaderDisplayType currentPage -1 0
// 0 anchorPageOffset transition
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
//
prefetchAdjacent(current: current)
}
// MARK: -
private func prefetchAdjacent(current: Int) {
for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
@@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
///
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
@@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator {
flipToChapter(spineIndex: next, completion: completion)
}
///
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex, current > 0 else { return }
flipToChapter(spineIndex: current - 1, completion: completion)
}
/// //
func flipToChapter(
spineIndex: Int,
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setNavigationTarget(spineIndex: spineIndex)
//
store.clearPrefetchTargets()
//
isSwitchingChapter = true
//
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
@@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator {
store.evict(spineIndex: idx)
}
//
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
@@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator {
return
}
//
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
@@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
func refreshSnapshot() {
guard let current = store.currentSpineIndex,
let currentChapter = store.chapterData(for: current) else { return }
//
guard isReaderIdle() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshSnapshot()
@@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: - P1:
///
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
@@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator {
prefetchAdjacent(current: spineIndex)
}
// MARK: -
private func handle(error: Error) {
//
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
#endif
// loading
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
//
if self.currentSnapshot == nil {
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
@@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator {
return true
}
///
private var isSwitchingChapter: Bool = false
///
private var isApplyingSnapshot: Bool = false
}
@@ -1,26 +1,17 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
///
let chapters: [RDEPUBRuntimeChapter]
/// RDReaderView
/// RDEPUBTextPage
/// flattenedPages 0
let flattenedPages: [RDEPUBTextPage]
/// chapters
let anchorChapterIndex: Int
/// flattenedPages 0
let anchorPageOffset: Int
/// spineIndex
let windowStartSpineIndex: Int
// MARK: -
///
static func from(
chapters: [RDEPUBRuntimeChapter],
anchorSpineIndex: Int
@@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot {
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
//
var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in sortedChapters.enumerated() {
for var page in ch.pages {
@@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot {
)
}
// MARK: -
/// flattenedPages ->
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
@@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot {
return nil
}
/// flattenedPages -> spineIndex
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
///
var pageCount: Int { flattenedPages.count }
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
@@ -1,13 +1,18 @@
import Foundation
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
final class RDEPUBPageResolver {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
@@ -1,26 +1,23 @@
import Foundation
final class RDEPUBRuntimeChapter {
let spineIndex: Int
let href: String
let title: String
///
var sourceAttributedString: NSAttributedString?
///
let typesetAttributedString: NSAttributedString
///
let layouter: RDEPUBTextLayouter
///
let pageRanges: [NSRange]
///
let pages: [RDEPUBTextPage]
///
let chapterOffsetMap: RDEPUBChapterOffsetMap
init(
@@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter {
self.chapterOffsetMap = chapterOffsetMap
}
/// sourceAttributedString
func releaseSourceText() {
sourceAttributedString = nil
}
@@ -1,9 +1,14 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}
@@ -1,8 +1,11 @@
import CryptoKit
extension String {
var sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}
@@ -1,65 +1,56 @@
import Foundation
///
struct RDEPUBBackgroundCoverageSegment {
/// spineIndex
let lowerSpineIndex: Int
/// spineIndex
let upperSpineIndex: Int
///
let pageMap: RDEPUBBookPageMap
/// spineIndex
let resolvedSpineIndices: Set<Int>
///
let generatedAt: CFAbsoluteTime
///
let renderSignature: String
///
let estimatedMemoryBytes: Int
/// spineIndex
func contains(spineIndex: Int) -> Bool {
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
}
/// spineIndex
func distance(to spineIndex: Int) -> Int {
if contains(spineIndex: spineIndex) { return 0 }
return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
}
}
///
struct RDEPUBBackgroundCoverageStorePolicy {
///
let maxResidentSegments: Int
///
let maxChaptersPerSegment: Int
///
let memoryBudgetBytes: Int
///
static let `default` = RDEPUBBackgroundCoverageStorePolicy(
maxResidentSegments: 8,
maxChaptersPerSegment: 256,
memoryBudgetBytes: 8 * 1024 * 1024 // 8MB
memoryBudgetBytes: 8 * 1024 * 1024
)
}
///
final class RDEPUBBackgroundCoverageStore {
private unowned let context: RDEPUBReaderContext
///
private let policy: RDEPUBBackgroundCoverageStorePolicy
///
private var segments: [RDEPUBBackgroundCoverageSegment] = []
///
private var currentMemoryBytes: Int = 0
/// 访 LRU
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
@@ -67,12 +58,10 @@ final class RDEPUBBackgroundCoverageStore {
self.policy = policy
}
///
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
//
evictIfNeeded(forNewSegment: segment)
//
var merged = false
for (index, existing) in segments.enumerated() {
if canMerge(existing, segment) {
@@ -94,7 +83,6 @@ final class RDEPUBBackgroundCoverageStore {
currentMemoryBytes += segment.estimatedMemoryBytes
}
// 访
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
RDEPUBBackgroundTrace.log(
@@ -103,7 +91,6 @@ final class RDEPUBBackgroundCoverageStore {
)
}
/// spineIndex
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
if let segment {
@@ -112,7 +99,6 @@ final class RDEPUBBackgroundCoverageStore {
return segment
}
/// spineIndex
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { segment in
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
@@ -123,19 +109,16 @@ final class RDEPUBBackgroundCoverageStore {
return segment
}
///
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
segments
}
///
func clearAll() {
segments.removeAll()
currentMemoryBytes = 0
lastAccessTime.removeAll()
}
///
func clearColdSegments(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
@@ -151,7 +134,6 @@ final class RDEPUBBackgroundCoverageStore {
}
}
///
func handleMemoryWarning(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
@@ -161,15 +143,13 @@ final class RDEPUBBackgroundCoverageStore {
"memory warning: clearing cold segments, current=\(currentMemoryBytes)B"
)
//
clearColdSegments(
activeWindowSpineIndices: activeWindowSpineIndices,
protectedSpineIndices: protectedSpineIndices
)
//
if currentMemoryBytes > policy.memoryBudgetBytes {
//
let sorted = segments.sorted { lhs, rhs in
let lhsDistance = lhs.resolvedSpineIndices.map { idx in
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
@@ -194,24 +174,20 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
//
while segments.count >= policy.maxResidentSegments {
evictLeastRecentlyUsed()
}
//
while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
evictLeastRecentlyUsed()
}
}
/// 使
private func evictLeastRecentlyUsed() {
guard !segments.isEmpty else { return }
// 访
var oldestTime = CFAbsoluteTimeGetCurrent()
var oldestIndex = 0
for (index, segment) in segments.enumerated() {
@@ -232,33 +208,27 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
//
guard lhs.renderSignature == rhs.renderSignature else { return false }
//
let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
return overlap
}
///
private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
let newChapterCount = newUpper - newLower + 1
//
if newChapterCount > policy.maxChaptersPerSegment {
//
return nil
}
// resolvedSpineIndices
let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
//
let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
@@ -274,7 +244,6 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
@@ -300,7 +269,6 @@ final class RDEPUBBackgroundCoverageStore {
return builder.build()
}
///
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
256 + pageMap.entries.count * 96 + resolvedCount * 16
}
@@ -1,19 +1,15 @@
import Foundation
///
///
///
struct RDEPUBBackgroundPriorityPolicy {
///
let hotRadius: Int
///
let warmRadius: Int
///
let maxWarmJumpAnchors: Int
///
let coldLaneShare: Double
///
static let `default` = RDEPUBBackgroundPriorityPolicy(
hotRadius: 24,
warmRadius: 96,
@@ -21,7 +17,6 @@ struct RDEPUBBackgroundPriorityPolicy {
coldLaneShare: 0.15
)
///
static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
let warmRadius = min(max(hotRadius * 3, 32), 192)
@@ -34,7 +29,6 @@ struct RDEPUBBackgroundPriorityPolicy {
}
}
///
enum RDEPUBPriorityBand: Int, Comparable {
case hot = 0
case warmPrimary = 1
@@ -46,39 +40,32 @@ enum RDEPUBPriorityBand: Int, Comparable {
}
}
///
struct RDEPUBWarmJumpAnchor {
let spineIndex: Int
let timestamp: CFAbsoluteTime
let sequenceNumber: Int
}
///
struct RDEPUBMetadataParseWorkItem {
let spineIndex: Int
let generation: Int
let priorityBand: RDEPUBPriorityBand
///
var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
(priorityBand.rawValue, 0, 0, spineIndex)
}
}
///
final class RDEPUBBackgroundPriorityManager {
private unowned let context: RDEPUBReaderContext
///
private(set) var policy: RDEPUBBackgroundPriorityPolicy
///
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
/// generation
private(set) var currentGeneration: Int = 0
///
private var coldCursor: Int = 0
init(context: RDEPUBReaderContext) {
@@ -86,12 +73,10 @@ final class RDEPUBBackgroundPriorityManager {
self.policy = .default
}
///
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
policy = newPolicy
}
///
func addWarmAnchor(spineIndex: Int) {
let anchor = RDEPUBWarmJumpAnchor(
spineIndex: spineIndex,
@@ -101,13 +86,12 @@ final class RDEPUBBackgroundPriorityManager {
warmAnchors.insert(anchor, at: 0)
// N
if warmAnchors.count > policy.maxWarmJumpAnchors {
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
}
currentGeneration += 1
coldCursor = 0 //
coldCursor = 0
RDEPUBBackgroundTrace.log(
"PriorityManager",
@@ -115,7 +99,6 @@ final class RDEPUBBackgroundPriorityManager {
)
}
/// spineIndex
func makeMetadataPriorityOrder(
allBuildableIndices: [Int],
currentSpineIndex: Int?,
@@ -124,7 +107,6 @@ final class RDEPUBBackgroundPriorityManager {
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
guard !uncachedIndices.isEmpty else { return [] }
// spineIndex
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
let band = classifySpineIndex(
spineIndex: spineIndex,
@@ -133,33 +115,29 @@ final class RDEPUBBackgroundPriorityManager {
return (spineIndex, band)
}
//
let sorted = items.sorted { lhs, rhs in
//
if lhs.band != rhs.band {
return lhs.band < rhs.band
}
//
let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
if lhsDistanceToCurrent != rhsDistanceToCurrent {
return lhsDistanceToCurrent < rhsDistanceToCurrent
}
// spineIndex
return lhs.spineIndex < rhs.spineIndex
}
return sorted.map { $0.spineIndex }
}
/// spineIndex
private func classifySpineIndex(
spineIndex: Int,
currentSpineIndex: Int?
) -> RDEPUBPriorityBand {
//
if let current = currentSpineIndex {
let distance = abs(spineIndex - current)
if distance <= policy.hotRadius {
@@ -167,7 +145,6 @@ final class RDEPUBBackgroundPriorityManager {
}
}
//
for (index, anchor) in warmAnchors.enumerated() {
let distance = abs(spineIndex - anchor.spineIndex)
if distance <= policy.warmRadius {
@@ -175,16 +152,13 @@ final class RDEPUBBackgroundPriorityManager {
}
}
//
return .cold
}
///
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
warmAnchors
}
///
func reset() {
warmAnchors.removeAll()
currentGeneration = 0
@@ -1,56 +1,47 @@
import Foundation
///
///
///
/// -
/// -
struct RDEPUBJumpSession {
/// spineIndex
let anchorSpineIndex: Int
///
let createdAt: CFAbsoluteTime
/// spineIndex
let protectedSpineIndices: Set<Int>
///
let sequenceNumber: Int
///
let expiresAt: CFAbsoluteTime
///
let reason: Reason
///
enum Reason {
case tableOfContentsJump
case bookmarkJump
case searchJump
}
///
enum EndReason {
///
case coverageComplete
///
case navigatedAway
///
case timeout
///
case superseded
}
}
/// JumpSession
public struct RDEPUBJumpSessionPolicy: Equatable {
///
public let exitPageThreshold: Int
///
public let timeout: TimeInterval
///
public let idleGracePeriod: TimeInterval
///
public let protectedNeighborRadius: Int
///
public static let `default` = RDEPUBJumpSessionPolicy(
exitPageThreshold: 6,
timeout: 20,
@@ -71,26 +62,20 @@ public struct RDEPUBJumpSessionPolicy: Equatable {
}
}
/// JumpSession
final class RDEPUBJumpSessionManager {
private unowned let context: RDEPUBReaderContext
/// JumpSession
private(set) var activeSession: RDEPUBJumpSession?
///
private var nextSequenceNumber: Int = 0
///
private var consecutivePageCount: Int = 0
///
private var lastPageDirection: PageDirection?
///
private var lastActivityTime: CFAbsoluteTime = 0
///
enum PageDirection {
case forward
case backward
@@ -100,7 +85,6 @@ final class RDEPUBJumpSessionManager {
self.context = context
}
/// JumpSession
@discardableResult
func createSession(
anchorSpineIndex: Int,
@@ -110,7 +94,6 @@ final class RDEPUBJumpSessionManager {
let policy = context.configuration.jumpSessionPolicy
let now = CFAbsoluteTimeGetCurrent()
//
var protectedIndices: Set<Int> = [anchorSpineIndex]
for offset in 1...policy.protectedNeighborRadius {
let lower = anchorSpineIndex - offset
@@ -146,7 +129,6 @@ final class RDEPUBJumpSessionManager {
return session
}
///
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
guard activeSession != nil else { return }
@@ -161,29 +143,24 @@ final class RDEPUBJumpSessionManager {
}
}
///
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
guard let session = activeSession else {
return true // Session
return true
}
//
let protectedIndices = session.protectedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
Double(protectedIndices.count)
// 80%
return coverageRatio >= 0.8
}
/// Session
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
guard let session = activeSession else { return nil }
let now = CFAbsoluteTimeGetCurrent()
let policy = context.configuration.jumpSessionPolicy
// 1.
if now >= session.expiresAt {
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
RDEPUBBackgroundTrace.log(
@@ -194,7 +171,6 @@ final class RDEPUBJumpSessionManager {
}
}
// 2.
if !session.protectedSpineIndices.contains(currentSpineIndex) {
if consecutivePageCount >= policy.exitPageThreshold {
RDEPUBBackgroundTrace.log(
@@ -204,14 +180,13 @@ final class RDEPUBJumpSessionManager {
return .navigatedAway
}
} else {
//
consecutivePageCount = 0
}
return nil
}
/// Session
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
guard let session = activeSession else { return }
RDEPUBBackgroundTrace.log(
@@ -223,7 +198,6 @@ final class RDEPUBJumpSessionManager {
lastPageDirection = nil
}
/// Session
func clearSession() {
activeSession = nil
consecutivePageCount = 0
@@ -1,33 +1,31 @@
import Foundation
///
enum RDEPUBPageMapTakeoverDecision {
///
case keepCurrentWindow
///
case expandWindow(RDEPUBBackgroundCoverageSegment)
///
case segmentReplace(RDEPUBBackgroundCoverageSegment)
///
case fullReplace(RDEPUBBookPageMap)
}
///
final class RDEPUBPageMapReconciliationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
///
func evaluateTakeover(
candidatePageMap: RDEPUBBookPageMap?,
candidateSegment: RDEPUBBackgroundCoverageSegment?,
currentWindow: RDEPUBBookPageMap?,
jumpSession: RDEPUBJumpSession?
) -> RDEPUBPageMapTakeoverDecision {
//
guard let currentWindow else {
if let candidatePageMap {
return .fullReplace(candidatePageMap)
@@ -35,7 +33,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
//
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
@@ -46,11 +43,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}) ?? 0
// JumpSession
if let jumpSession {
let protectedIndices = jumpSession.protectedSpineIndices
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
//
if let candidateSegment {
let candidateIndices = candidateSegment.resolvedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
@@ -66,7 +62,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let currentSpineIndex {
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
@@ -85,7 +80,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let candidateSegment {
let currentRenderSignature = context.currentRenderSignature()
if candidateSegment.renderSignature != currentRenderSignature {
@@ -97,7 +91,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let candidateSegment {
return evaluateSegmentTakeover(
candidateSegment: candidateSegment,
@@ -119,7 +112,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
///
private func evaluateSegmentTakeover(
candidateSegment: RDEPUBBackgroundCoverageSegment,
currentWindow: RDEPUBBookPageMap,
@@ -129,14 +121,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let candidateIndices = candidateSegment.resolvedSpineIndices
//
if let currentSpineIndex {
if !candidateIndices.contains(currentSpineIndex) {
return .keepCurrentWindow
}
}
//
if let currentSpineIndex {
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
@@ -146,21 +136,20 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
if isContinuous {
//
return .expandWindow(candidateSegment)
} else {
//
let overlap = currentIndices.intersection(candidateIndices)
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
if overlapRatio > 0.5 {
//
return .segmentReplace(candidateSegment)
}
}
@@ -168,7 +157,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
///
private func evaluateFullPageMapTakeover(
candidatePageMap: RDEPUBBookPageMap,
currentWindow: RDEPUBBookPageMap,
@@ -177,14 +165,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
//
if let currentSpineIndex {
if !candidateIndices.contains(currentSpineIndex) {
return .keepCurrentWindow
}
}
//
if let currentSpineIndex {
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
@@ -194,7 +180,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
let isComplete = candidateIndices.count >= currentWindow.entries.count
if isComplete {
return .fullReplace(candidatePageMap)
@@ -203,7 +188,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
/// spineIndex
func protectedSpineIndices(
currentSpineIndex: Int?,
jumpSession: RDEPUBJumpSession?
@@ -212,7 +196,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
if let currentSpineIndex {
indices.insert(currentSpineIndex)
//
if currentSpineIndex > 0 {
indices.insert(currentSpineIndex - 1)
}
@@ -1,13 +1,7 @@
import UIKit
/// EPUB
///
///
/// - highlightannotation
/// - bookmark
/// -
/// -
final class RDEPUBReaderAnnotationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -18,19 +12,16 @@ final class RDEPUBReaderAnnotationCoordinator {
context.controller
}
/// ID
func bookmark(withID id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
return controller.activeBookmarks.first { $0.id == id }
}
/// ID
func highlight(withID id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
return controller.activeHighlights.first { $0.id == id }
}
///
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
if let selection, !selection.isEmpty {
applySelectionState(.selected(selection))
@@ -39,9 +30,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
/// `.selected` context chrome delegate
/// `.idle` chrome delegate
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
context.selectionState = state
@@ -52,9 +40,6 @@ final class RDEPUBReaderAnnotationCoordinator {
case .selecting:
break
case .selected(let selection):
if controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
@@ -62,7 +47,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
@@ -72,7 +56,6 @@ final class RDEPUBReaderAnnotationCoordinator {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
}
/// /线/
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
@@ -115,7 +98,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return newHighlight
}
/// upsert ID
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -132,7 +114,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return scopedHighlight
}
/// ID
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -144,7 +125,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return removed
}
///
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -156,7 +136,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.activeHighlights[index]
}
///
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let highlight = highlight(withID: id) else {
@@ -165,7 +144,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return navigate(to: highlight, animated: animated)
}
///
func removeAllHighlights() {
guard let controller else { return }
guard !controller.activeHighlights.isEmpty else { return }
@@ -173,7 +151,6 @@ final class RDEPUBReaderAnnotationCoordinator {
persistHighlightsAndRefreshContent()
}
/// spine
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
@@ -190,7 +167,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor
rangeAnchor: selection.location.rangeAnchor,
cfi: selection.location.cfi,
lastCFI: selection.location.lastCFI,
rangeCFI: selection.location.rangeCFI
)
return RDEPUBSelection(
bookIdentifier: controller.currentBookIdentifier,
@@ -201,7 +181,6 @@ final class RDEPUBReaderAnnotationCoordinator {
)
}
///
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
@@ -231,7 +210,6 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(navigationController, animated: true)
}
/// /线/
func presentAnnotationCreation() {
guard let controller else { return }
guard controller.configuration.allowsHighlights,
@@ -241,7 +219,6 @@ final class RDEPUBReaderAnnotationCoordinator {
presentAnnotationActionSheet(for: currentSelection)
}
/// /
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
guard let controller else { return }
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
@@ -263,7 +240,6 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(alert, animated: true)
}
///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
@@ -277,7 +253,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -299,7 +274,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return newBookmark
}
///
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -315,7 +289,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return addBookmark(note: note)
}
/// ID
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -327,7 +300,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return removed
}
///
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
@@ -337,7 +309,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.restoreReadingLocation(bookmark.location, animated: animated)
}
///
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
@@ -374,7 +345,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor
rangeAnchor: highlight.location.rangeAnchor,
cfi: highlight.location.cfi,
lastCFI: highlight.location.lastCFI,
rangeCFI: highlight.location.rangeCFI
)
return RDEPUBHighlight(
id: highlight.id,
@@ -407,7 +381,21 @@ final class RDEPUBReaderAnnotationCoordinator {
}
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
controller.updateReaderChrome()
controller.refreshVisibleContentPreservingLocation()
refreshVisibleContentPreservingCurrentPage()
}
private func refreshVisibleContentPreservingCurrentPage() {
guard let controller else { return }
let currentPage = controller.readerView.currentPage
guard currentPage >= 0 else {
controller.refreshVisibleContentPreservingLocation()
return
}
controller.readerView.reloadData()
if controller.readerView.currentPage != currentPage {
controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
}
}
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
@@ -511,6 +499,11 @@ final class RDEPUBReaderAnnotationCoordinator {
return false
}
if let bookmarkCFI = bookmark.location.cfi,
let locationCFI = location.cfi {
return bookmarkCFI == locationCFI
}
if let bookmarkAnchor = bookmark.location.rangeAnchor,
let locationAnchor = location.rangeAnchor {
return bookmarkAnchor == locationAnchor
@@ -545,7 +538,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
@@ -559,7 +555,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
@@ -1,11 +1,5 @@
import UIKit
/// EPUB UI
///
///
/// - readerViewloadingIndicatorerrorLabel
/// -
/// -
final class RDEPUBReaderAssemblyCoordinator {
private unowned let context: RDEPUBReaderContext
@@ -13,7 +7,6 @@ final class RDEPUBReaderAssemblyCoordinator {
self.context = context
}
/// readerViewloadingIndicatorerrorLabel
func assembleInterface() {
guard let controller = context.controller,
let readerView = context.readerView else { return }
@@ -28,7 +21,6 @@ final class RDEPUBReaderAssemblyCoordinator {
#endif
}
///
func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime,
let controller = context.controller,
@@ -1,14 +1,7 @@
import UIKit
/// EPUB Chrome /
///
///
/// -
/// -
/// -
/// -
/// -
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -19,14 +12,11 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
context.controller
}
// MARK: - UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
//
context.runtime?.settingsPanelDidDisappear()
}
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
@@ -41,7 +31,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return toolView
}
///
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
let toolView = RDEPUBReaderBottomToolView()
toolView.onShowTableOfContents = { [weak self] in
@@ -62,7 +51,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return toolView
}
///
func updateReaderChrome() {
guard let controller else { return }
let uiState = makeUIState()
@@ -70,7 +58,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
updateSearchBar()
}
/// UI
func makeUIState() -> RDEPUBReaderUIState {
guard let controller else { return .empty }
return RDEPUBReaderUIState(
@@ -85,7 +72,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
)
}
/// UI
func applyUIState(_ state: RDEPUBReaderUIState) {
guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme)
@@ -107,18 +93,15 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
}
///
private func hasBookmarkAtCurrentLocation() -> Bool {
guard let controller else { return false }
return context.runtime?.annotationCoordinator.currentBookmark() != nil
}
///
func presentSettings() {
guard let controller else { return }
guard controller.configuration.showsSettingsPanel else { return }
// Runtime
context.runtime?.settingsPanelWillAppear()
let settingsController = RDEPUBReaderSettingsViewController(
@@ -147,7 +130,7 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller?.updateConfiguration { $0.theme = theme }
}
settingsController.onDismiss = { [weak self] in
// Runtime
self?.context.runtime?.settingsPanelDidDisappear()
}
@@ -157,7 +140,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller.present(navigationController, animated: true)
}
///
func presentTableOfContents() {
guard let controller else { return }
guard controller.configuration.showsTableOfContents else { return }
@@ -184,7 +166,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller.present(navigationController, animated: true)
}
/// /
func toggleSearchBar() {
guard let controller else { return }
if controller.isSearchBarVisible {
@@ -194,7 +175,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
}
///
func updateSearchBar() {
guard let controller else { return }
controller.searchBarView.apply(theme: controller.configuration.theme)
@@ -207,7 +187,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
}
/// pop dismiss
func handleBackAction() {
guard let controller else { return }
close(controller)
@@ -1,65 +1,51 @@
import UIKit
/// coordinator context 访便
///
/// context
/// - parserpublicationtextBookpages
/// - UI configurationbrightness
/// - persistence
/// - 便renderStylelayoutConfig
/// - controller UIKit
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
// MARK: -
/// UIKit
weak var controller: RDEPUBReaderController?
///
weak var readerView: RDReaderView?
///
var dependencies: RDEPUBReaderDependencies = .live
/// 便访
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
// MARK: -
/// EPUB
var parser: RDEPUBParser?
///
var publication: RDEPUBPublication?
///
var readingSession: RDEPUBReadingSession?
///
/// nil ChapterRuntimeStore 访
var textBook: RDEPUBTextBook?
/// 100KB/1000 NSAttributedString
var bookPageMap: RDEPUBBookPageMap?
///
var activeBookmarks: [RDEPUBBookmark] = []
///
var activeHighlights: [RDEPUBHighlight] = []
///
var currentBookIdentifier: String?
///
var paginationToken = UUID()
/// Web
var paginator: RDEPUBPaginator?
///
var searchState: RDEPUBSearchState?
/// BookPageMap
/// map
var pendingFullPageMap: RDEPUBBookPageMap?
///
var lastTextPaginationPageSize: CGSize?
/// OperationQueue
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
/// selectionState
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
@@ -70,38 +56,30 @@ final class RDEPUBReaderContext {
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller
///
var configuration: RDEPUBReaderConfiguration = .default
///
var persistence: RDEPUBReaderPersistence?
/// EPUB URL
var epubURL: URL = URL(string: "about:blank")!
///
var isRepaginating: Bool = false
///
var didStartInitialLoad: Bool = false
///
var isExternalTextBook: Bool = false
/// URL
var textFileURL: URL?
///
var textBookCache = RDEPUBTextBookCache()
// MARK: -
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool = false
var didStartInitialLoad: Bool = false
var isExternalTextBook: Bool = false
var textFileURL: URL?
var textBookCache = RDEPUBTextBookCache()
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
}
// MARK: - 便
/// readerView
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
@@ -115,12 +93,10 @@ final class RDEPUBReaderContext {
)
}
///
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
/// readerView
func currentTextPageSize() -> CGSize {
if Thread.isMainThread {
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
@@ -149,7 +125,6 @@ final class RDEPUBReaderContext {
return dependencies.environment.fallbackViewportSize
}
///
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
@@ -161,7 +136,6 @@ final class RDEPUBReaderContext {
)
}
///
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
@@ -169,7 +143,7 @@ final class RDEPUBReaderContext {
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
//
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
@@ -179,48 +153,39 @@ final class RDEPUBReaderContext {
)
}
///
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
///
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
///
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
///
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
}
///
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
///
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
/// EPUB
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
/// Web
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
/// EPUB
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
}
@@ -252,8 +217,6 @@ final class RDEPUBReaderContext {
)
}
/// 使 contentHash HTML SHA-256
///
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
@@ -262,8 +225,6 @@ final class RDEPUBReaderContext {
)
}
/// 使 contentHash
/// live context
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
@@ -277,7 +238,6 @@ final class RDEPUBReaderContext {
)
}
///
func currentRenderSignature() -> String {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
@@ -311,36 +271,30 @@ final class RDEPUBReaderContext {
}
}
///
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
controller?.currentVisibleLocation()
}
///
func persistenceLocation() -> RDEPUBLocation? {
guard let currentBookIdentifier else { return nil }
return persistence?.loadLocation(for: currentBookIdentifier)
}
///
func persist(location: RDEPUBLocation) {
guard let currentBookIdentifier else { return }
persistence?.saveLocation(location, for: currentBookIdentifier)
}
/// /
func markUserNavigationActivity() {
activityLock.lock()
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
activityLock.unlock()
}
/// /
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
activityLock.lock()
let timestamp = lastUserNavigationTimestamp
@@ -349,7 +303,6 @@ final class RDEPUBReaderContext {
return CFAbsoluteTimeGetCurrent() - timestamp
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil }
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
@@ -358,42 +311,34 @@ final class RDEPUBReaderContext {
.flatMap { textBook.chapterData(for: $0.href) }
}
///
func showLoading() {
controller?.showLoading()
}
///
func hideLoading() {
controller?.hideLoading()
}
///
func handle(error: Error) {
controller?.handle(error: error)
}
///
func updateReaderChrome() {
controller?.updateReaderChrome()
}
///
func refreshVisibleContentPreservingLocation() {
controller?.refreshVisibleContentPreservingLocation()
}
///
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
controller?.restoreReadingLocation(location, animated: animated) ?? false
}
///
func repaginatePreservingCurrentLocation() {
controller?.repaginatePreservingCurrentLocation()
}
///
func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration()
}
@@ -1,17 +1,13 @@
// RDEPUBReaderDependencies.swift
// EPUB
import UIKit
///
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
/// 0.0 ~ 1.0
var currentBrightness: CGFloat { get set }
///
var fallbackViewportSize: CGSize { get }
}
/// UIScreen
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
public init() {}
@@ -25,29 +21,20 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
}
}
/// EPUB 便
public struct RDEPUBReaderDependencies {
///
public var environment: any RDEPUBReaderDisplayEnvironment
/// EPUB
public var makeParser: () -> RDEPUBParser
///
public var makePaginator: () -> RDEPUBPaginator
///
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
///
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
///
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
///
/// - Parameters:
/// - environment:
/// - makeParser: EPUB
/// - makePaginator:
/// - makeTextBookBuilder:
/// - makePlainTextBookBuilder:
/// - makeTextRenderer:
public init(
environment: any RDEPUBReaderDisplayEnvironment,
makeParser: @escaping () -> RDEPUBParser,
@@ -64,7 +51,6 @@ public struct RDEPUBReaderDependencies {
self.makeTextRenderer = makeTextRenderer
}
/// 使
public static var live: RDEPUBReaderDependencies {
RDEPUBReaderDependencies(
environment: RDEPUBUIScreenEnvironment(),
@@ -1,11 +1,5 @@
import Foundation
/// EPUB EPUB
///
///
/// -
/// - EPUB Publication
/// -
final class RDEPUBReaderLoadCoordinator {
private unowned let context: RDEPUBReaderContext
@@ -13,7 +7,6 @@ final class RDEPUBReaderLoadCoordinator {
self.context = context
}
///
func startInitialLoadIfNeeded() {
guard let controller = context.controller,
let readerView = context.readerView,
@@ -26,7 +19,6 @@ final class RDEPUBReaderLoadCoordinator {
loadPublication()
}
/// EPUB 线
func loadPublication() {
guard let controller = context.controller else { return }
context.showLoading()
@@ -65,7 +57,6 @@ final class RDEPUBReaderLoadCoordinator {
}
}
/// //
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -1,23 +1,14 @@
import Foundation
/// EPUB
///
///
/// -
/// -
/// -
/// -
final class RDEPUBReaderLocationCoordinator {
private unowned let context: RDEPUBReaderContext
/// spineIndex
private var lastPageChangeSpineIndex: Int?
init(context: RDEPUBReaderContext) {
self.context = context
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -57,13 +48,11 @@ final class RDEPUBReaderLocationCoordinator {
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
// JumpSession
recordPageChangeIfNeeded()
return true
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let readerView = context.readerView else {
@@ -74,8 +63,7 @@ final class RDEPUBReaderLocationCoordinator {
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
return location
}
// resolvedTextLocation nil
// readingSession activePages 退
if let readingSession = context.readingSession,
readingSession.activePages.indices.contains(readerView.currentPage) {
return readingSession.fallbackLocation(
@@ -87,7 +75,6 @@ final class RDEPUBReaderLocationCoordinator {
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
}
///
func persistenceLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else {
@@ -96,7 +83,6 @@ final class RDEPUBReaderLocationCoordinator {
return controller.persistence?.loadLocation(for: currentBookIdentifier)
}
///
func persist(location: RDEPUBLocation) {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else { return }
@@ -106,7 +92,6 @@ final class RDEPUBReaderLocationCoordinator {
controller.updateReaderChrome()
}
/// JumpSession
func recordPageChangeIfNeeded() {
guard let runtime = context.runtime,
let bookPageMap = context.bookPageMap,
@@ -127,7 +112,6 @@ final class RDEPUBReaderLocationCoordinator {
lastPageChangeSpineIndex = currentSpineIndex
// JumpSession
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
currentSpineIndex: currentSpineIndex,
@@ -137,7 +121,6 @@ final class RDEPUBReaderLocationCoordinator {
}
}
///
func resetPageChangeState() {
lastPageChangeSpineIndex = nil
}
@@ -1,17 +1,13 @@
import Foundation
/// EPUB
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private final class MetadataParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
@@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator {
}
private final class MetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
@@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator {
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
}
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
@@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
context.textBook = textBook
@@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
/// Fixed Layout Web
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
@@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator {
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
/// 使
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
@@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator {
paginatePublication(restoreLocation: restoreLocation)
}
///
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
@@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
@@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
@@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator {
runtime: runtime
)
}
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
runtime: runtime
)
}
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
DispatchQueue.main.async {
@@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
runtime.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
@@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator {
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication,
maxChapterCount: context.configuration.onDemandChapterWindowSize
)
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
}
return chapters
}
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < normalizedMaxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == normalizedMaxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
@@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator {
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
///
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
@@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator {
!cancellationController.isCancelled,
context.paginationToken == token else { return }
// contentHash + SHA-256
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
@@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
// 使 spineIndex
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
@@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator {
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
@@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
// 退
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
@@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator {
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
// BackgroundCoverageStore
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
@@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
@@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator {
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator {
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
//
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
@@ -1,65 +1,78 @@
import UIKit
/// EPUB
///
/// Facade
final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
return loader
}()
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
///
var isSettingsPanelOpen: Bool = false
///
var needsFullRepaginationAfterSettingsClose: Bool = false
///
private var settingsPreviewGeneration: Int = 0
/// preview
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
/// preview
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
private struct SettingsPreviewAnchor {
let spineIndex: Int
let href: String
let offset: Int
}
init(context: RDEPUBReaderContext) {
self.context = context
}
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
chromeCoordinator.makeTopToolView()
}
///
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
chromeCoordinator.makeBottomToolView()
}
///
func startInitialLoadIfNeeded() {
loadCoordinator.startInitialLoadIfNeeded()
}
///
func reloadBook() {
guard let readerView = context.readerView else { return }
context.didStartInitialLoad = false
@@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime {
startInitialLoadIfNeeded()
}
///
/// - Parameters:
/// - location:
/// - animated:
/// - Returns:
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
@@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime {
return true
}
///
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
@@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
}
///
func search(keyword: String) {
searchCoordinator.search(keyword: keyword)
}
///
@discardableResult
func searchNext() -> Bool {
searchCoordinator.searchNext()
}
///
@discardableResult
func searchPrevious() -> Bool {
searchCoordinator.searchPrevious()
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
searchCoordinator.selectSearchMatch(at: index)
}
///
func clearSearch() {
searchCoordinator.clearSearch()
}
@@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPresentation(for: page)
}
///
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
///
func presentSettings() {
chromeCoordinator.presentSettings()
}
///
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
///
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
/// Publication
func loadPublication() {
loadCoordinator.loadPublication()
}
/// Publication
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime {
)
}
/// Publication
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
/// TextBook
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
///
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime {
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
// map
// map
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
context.pendingFullPageMap = bookPageMap
}
/// BookPageMap
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
//
guard !controller.isRepaginating else { return }
// 使
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
@@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime {
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
// candidatePageMap
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
break
}
}
///
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
@@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime {
context.pendingFullPageMap = nil
// map
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
// map
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
@@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime {
readerView.reloadPageCountOnly()
}
// JumpSessioncoverage-complete
if let currentLocation,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
@@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime {
}
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
///
func repaginatePreservingCurrentLocation() {
//
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
@@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime {
}
}
/// preview
private func scheduleSettingsPreviewRepagination() {
pendingSettingsPreviewWorkItem?.cancel()
settingsPreviewGeneration += 1
@@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime {
return
}
self.pendingSettingsPreviewWorkItem = nil
let previewAnchor = self.captureSettingsPreviewAnchor()
self.chapterRuntimeStore.invalidateAllForSettingsChange()
self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration)
self.repaginateCurrentChapterOnly(
previewGeneration: previewGeneration,
previewAnchor: previewAnchor
)
}
pendingSettingsPreviewWorkItem = workItem
DispatchQueue.main.asyncAfter(
@@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime {
)
}
/// 使
private func repaginateCurrentChapterOnly(previewGeneration: Int) {
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
guard let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return nil }
let absolutePageIndex = readerView.currentPage
guard absolutePageIndex >= 0,
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
chapter.pages.indices.contains(localPageIndex) else {
return nil
}
let page = chapter.pages[localPageIndex]
let offset = page.contentRange.length > 0
? page.contentRange.location
: page.pageStartOffset
return SettingsPreviewAnchor(
spineIndex: spineIndex,
href: chapter.href,
offset: offset
)
}
private func repaginateCurrentChapterOnly(
previewGeneration: Int,
previewAnchor: SettingsPreviewAnchor?
) {
guard let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return }
@@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime {
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
readerView.reloadData()
if let targetPage = self.settingsPreviewTargetPage(
in: chapter,
for: previewAnchor
) {
readerView.transitionToPage(pageNum: targetPage, animated: false)
return
}
if let previewLocation,
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: 0, animated: false)
case .failure(let error):
@@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime {
}
}
///
private func settingsPreviewTargetPage(
in chapter: RDEPUBRuntimeChapter,
for anchor: SettingsPreviewAnchor?
) -> Int? {
guard let anchor,
anchor.spineIndex == chapter.spineIndex,
anchor.href == chapter.href,
!chapter.pages.isEmpty else {
return nil
}
if let exactPage = chapter.pages.first(where: { page in
let lowerBound = page.contentRange.location
let upperBound = page.contentRange.location + page.contentRange.length
if page.contentRange.length == 0 {
return anchor.offset == lowerBound
}
return anchor.offset >= lowerBound && anchor.offset < upperBound
}) {
return exactPage.pageIndexInChapter
}
if let nextPage = chapter.pages.first(where: { page in
page.contentRange.location > anchor.offset
}) {
return nextPage.pageIndexInChapter
}
return max(chapter.pages.count - 1, 0)
}
func settingsPanelWillAppear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
@@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime {
settingsPreviewGeneration += 1
}
///
func settingsPanelDidDisappear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
isSettingsPanelOpen = false
settingsPreviewGeneration += 1
//
if needsFullRepaginationAfterSettingsClose {
needsFullRepaginationAfterSettingsClose = false
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
@@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime {
}
}
///
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
/// TextBook
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime {
)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
///
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime {
return false
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
@@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime {
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
@@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime {
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
context.readerView?.reloadData()
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
//
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
@@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime {
return
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
let isNearStart = currentPageNumber <= minimumTrailingPages
//
var spineIndicesToAppend: [Int] = []
if isNearEnd {
//
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
} else if isNearStart {
//
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
} else {
//
return
}
@@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime {
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
chapterRuntimeStore.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
chapterRuntimeStore.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"initial open prefetch forward spine=\(spineIndex)"
)
chapterLoader.loadChapter(
spineIndex: spineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
func clearOnDemandPageModeState() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange()
@@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime {
backgroundCoverageStore.clearAll()
}
///
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
@@ -893,6 +968,76 @@ final class RDEPUBReaderRuntime {
return builder.build()
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else {
break
}
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
}
guard !appendedEntries.isEmpty else { return }
let existingEntries = currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
}
var absolutePageStart = 0
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
let normalizedEntry = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalizedEntry
}
let newMap = RDEPUBBookPageMap(entries: newEntries)
guard newMap.totalPages > currentMap.totalPages else { return }
RDEPUBBackgroundTrace.log(
"Runtime",
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
@@ -1,7 +1,7 @@
import Foundation
///
final class RDEPUBReaderSearchCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -12,8 +12,6 @@ final class RDEPUBReaderSearchCoordinator {
context.controller
}
///
/// - Parameter keyword:
func search(keyword: String) {
guard let controller else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -37,21 +35,16 @@ final class RDEPUBReaderSearchCoordinator {
}
}
///
/// - Returns:
@discardableResult
func searchNext() -> Bool {
advanceSearch(by: 1)
}
///
/// - Returns:
@discardableResult
func searchPrevious() -> Bool {
advanceSearch(by: -1)
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
@@ -66,7 +59,6 @@ final class RDEPUBReaderSearchCoordinator {
return navigateToCurrentSearchMatch(animated: true)
}
///
func clearSearch() {
guard let controller else { return }
controller.searchState = nil
@@ -74,9 +66,6 @@ final class RDEPUBReaderSearchCoordinator {
controller.refreshVisibleContentPreservingLocation()
}
///
/// - Parameter page:
/// - Returns: nil
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
guard let controller else { return nil }
guard let searchState = controller.searchState,
@@ -175,6 +164,7 @@ final class RDEPUBReaderSearchCoordinator {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
@@ -183,7 +173,9 @@ final class RDEPUBReaderSearchCoordinator {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
)
)
@@ -210,6 +202,7 @@ final class RDEPUBReaderSearchCoordinator {
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
@@ -266,7 +259,9 @@ final class RDEPUBReaderSearchCoordinator {
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
return controller.restoreReadingLocation(location, animated: animated)
}
@@ -298,7 +293,9 @@ final class RDEPUBReaderSearchCoordinator {
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
if let textBook = controller.textBook, let publication = controller.publication {
@@ -1,30 +1,26 @@
import Foundation
/// UI /
///
/// ChromeCoordinator AnnotationCoordinator
///
struct RDEPUBReaderUIState {
///
let canToggleBookmark: Bool
///
let hasBookmarkAtCurrentLocation: Bool
///
let canShowBookmarks: Bool
///
let canAddHighlight: Bool
///
let canShowHighlights: Bool
///
let showsTableOfContents: Bool
///
let allowsHighlights: Bool
///
let showsSettingsPanel: Bool
}
extension RDEPUBReaderUIState {
///
static let empty = RDEPUBReaderUIState(
canToggleBookmark: false,
hasBookmarkAtCurrentLocation: false,
@@ -1,13 +1,15 @@
import UIKit
///
///
final class RDEPUBReaderViewportMonitor {
private unowned let context: RDEPUBReaderContext
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
private var pendingPresentationRestoreLocation: RDEPUBLocation?
private var isWaitingForViewportTransitionCompletion = false
init(context: RDEPUBReaderContext) {
@@ -18,7 +20,6 @@ final class RDEPUBReaderViewportMonitor {
context.controller
}
///
func viewDidLayoutSubviews() {
guard let controller else { return }
guard let viewportSignature = currentViewportSignature() else { return }
@@ -41,8 +42,6 @@ final class RDEPUBReaderViewportMonitor {
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
}
///
/// - Parameter coordinator:
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
guard let controller else { return }
guard controller.didStartInitialLoad else { return }
@@ -57,7 +56,6 @@ final class RDEPUBReaderViewportMonitor {
}
}
///
func resetForReload() {
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
@@ -65,19 +63,16 @@ final class RDEPUBReaderViewportMonitor {
isWaitingForViewportTransitionCompletion = false
}
///
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
defer { pendingPresentationRestoreLocation = nil }
return pendingPresentationRestoreLocation
}
/// 使
func capturePendingPresentationRestoreLocation() {
guard let controller else { return }
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
}
///
func processPendingChangeAfterPagination() {
guard let pendingReason = pendingViewportChangeReason else { return }
pendingViewportChangeReason = nil
@@ -86,7 +81,6 @@ final class RDEPUBReaderViewportMonitor {
}
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
guard let controller else { return nil }
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
@@ -102,7 +96,6 @@ final class RDEPUBReaderViewportMonitor {
)
}
/// TextBook
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -1,23 +1,16 @@
// RDEPUBSelectionState.swift
//
// view controller
//
import Foundation
///
/// view/controller/coordinator `currentSelection != nil`
enum RDEPUBSelectionState: Equatable {
///
case idle
///
case selecting(anchor: Int)
///
case selected(RDEPUBSelection)
/// // idle
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
/// selecting / selected / committingAction
var hasSelection: Bool {
switch self {
case .idle:
@@ -27,7 +20,6 @@ enum RDEPUBSelectionState: Equatable {
}
}
///
var selection: RDEPUBSelection? {
switch self {
case .idle, .selecting:
@@ -1,26 +1,18 @@
import UIKit
// MARK: -
/// EPUB
///
public enum RDEPUBTextRenderingEngine: Equatable {
/// DTCoreText HTML/CSS NSAttributedString
case dtCoreText
}
// MARK: -
///
/// 使 bundle
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
/// 线
case system
/// 线
case serif
///
case rounded
///
case monospaced
public var displayName: String {
@@ -54,108 +46,58 @@ public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
}
}
// MARK: -
/// EPUB
/// EPUBUI
/// RDEPUBReaderController
public struct RDEPUBReaderConfiguration: Equatable {
// MARK:
/// pt 15
public var fontSize: CGFloat
/// 1.6 1.6
public var lineHeightMultiple: CGFloat
///
public var fontChoice: RDEPUBReaderFontChoice
///
public var numberOfColumns: Int
/// 20pt
public var columnGap: CGFloat
/// .pageCurl仿 .scroll
public var displayType: RDReaderView.DisplayType
///
public var landscapeDualPageEnabled: Bool
// MARK: UI
///
public var showsTableOfContents: Bool
///
public var allowsHighlights: Bool
///
public var showsSettingsPanel: Bool
// MARK:
/// reflowable 40pt 16pt
public var reflowableContentInsets: UIEdgeInsets
/// fixed layout
public var fixedContentInset: UIEdgeInsets
// MARK:
/// // .light
public var theme: RDEPUBReaderTheme
///
public var darkImageAdjustmentEnabled: Bool
/// 0 0.12 ~ 0.2
public var darkImageBlendRatio: CGFloat
///
public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// //
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 使 DTCoreText
public var textRenderingEngine: RDEPUBTextRenderingEngine
/// 3 3...15
public var onDemandChapterWindowSize: Int
/// CPU
/// profiling renderTotalMs wallClockMs
/// writeTotalMs I/O cpuCount * 1.25~1.5 I/O
public var metadataParsingConcurrency: Int
// MARK: JumpSession
/// JumpSession
public var jumpSessionPolicy: RDEPUBJumpSessionPolicy
// MARK:
/// URL scheme https
public var allowedExternalURLSchemes: Set<String>
/// true
public var requiresExternalLinkConfirmation: Bool
/// WebView inspectable false
public var allowsInspectableWebViews: Bool
/// WebView false
public var enablesVerboseWebViewLogging: Bool
// MARK:
///
/// - Parameters:
/// - fontSize: 15pt
/// - lineHeightMultiple: 1.6
/// - fontChoice:
/// - displayType: .pageCurl
/// - landscapeDualPageEnabled: true
/// - showsTableOfContents: true
/// - allowsHighlights: true
/// - showsSettingsPanel: true
/// - reflowableContentInsets:
/// - fixedContentInset:
/// - theme: .light
/// - darkImageAdjustmentEnabled:
/// - darkImageBlendRatio:
/// - fixedLayoutFit:
/// - fixedLayoutSpreadMode:
/// - textRenderingEngine:
/// - onDemandChapterWindowSize: 3...15
/// - metadataParsingConcurrency: CPU
/// - allowedExternalURLSchemes: URL scheme https
/// - requiresExternalLinkConfirmation: true
/// - allowsInspectableWebViews: inspectable false
/// - enablesVerboseWebViewLogging: WebView false
public init(
fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6,
@@ -210,11 +152,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
}
/// 使
public static let `default` = RDEPUBReaderConfiguration()
}
extension RDEPUBReaderConfiguration {
static func normalizedChapterWindowSize(_ size: Int) -> Int {
let clamped = max(3, min(15, size))
return clamped % 2 == 0 ? clamped + 1 : clamped
@@ -225,11 +167,8 @@ extension RDEPUBReaderConfiguration {
}
}
// MARK: -
extension RDEPUBReaderConfiguration {
/// RDEPUBPreferences
/// - Returns: Core 使
func makePreferences() -> RDEPUBPreferences {
RDEPUBPreferences(
fontSize: fontSize,
@@ -1,20 +1,15 @@
import UIKit
// MARK: -
///
/// RDReaderView.DisplayType
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
/// 仿
case pageCurl
///
case horizontalScroll
///
case verticalScroll
///
case horizontalCoverScroll
/// DisplayType UI DisplayMode
init(displayType: RDReaderView.DisplayType) {
switch displayType {
case .pageCurl:
@@ -26,8 +21,6 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
}
}
/// RDReaderView DisplayType
/// horizontalCoverScroll horizontalScroll
var displayType: RDReaderView.DisplayType {
switch self {
case .pageCurl:
@@ -40,25 +33,20 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
}
}
// MARK: -
///
/// RDEPUBReaderTheme
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
///
case light
///
case yellow
/// 绿
case green
///
case pink
///
case blue
/// /
case dark
///
public var theme: RDEPUBReaderTheme {
switch self {
case .light:
@@ -76,8 +64,6 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
}
}
///
/// nil
public init?(theme: RDEPUBReaderTheme) {
switch theme {
case .light:
@@ -98,30 +84,22 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
}
}
// MARK: -
///
///
///
public struct RDEPUBReaderSettings: Codable, Equatable {
// MARK:
/// 0.0 ~ 1.0nil 使
public var brightness: CGFloat?
/// ptnil 使
public var fontSize: CGFloat?
/// nil 使
public var fontChoice: RDEPUBReaderFontChoice?
/// nil 使
public var lineHeightMultiple: CGFloat?
/// nil 使
public var numberOfColumns: Int?
/// nil 使
public var displayMode: RDEPUBReaderDisplayMode?
/// nil 使
public var themePreset: RDEPUBReaderThemePreset?
/// nil
public init(
brightness: CGFloat? = nil,
fontSize: CGFloat? = nil,
@@ -140,10 +118,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
self.themePreset = themePreset
}
///
/// nil
/// - Parameter configuration: RDEPUBReaderConfiguration.default
/// - Returns:
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
var resolvedConfiguration = configuration
@@ -169,12 +143,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
return resolvedConfiguration
}
///
/// 0.0 ~ 1.0
/// - Parameters:
/// - configuration:
/// - brightness:
/// - Returns:
public static func capture(
configuration: RDEPUBReaderConfiguration,
brightness: CGFloat
@@ -1,33 +1,23 @@
import UIKit
// MARK: -
/// EPUB
///
/// iOS 13+ SF Symbols 退
final class RDEPUBReaderSettingsViewController: UIViewController {
// MARK:
/// 0.0 ~ 1.0
var onBrightnessChange: ((CGFloat) -> Void)?
/// pt
var onFontSizeChange: ((CGFloat) -> Void)?
///
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
///
var onLineHeightChange: ((CGFloat) -> Void)?
///
var onColumnCountChange: ((Int) -> Void)?
///
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
///
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
///
var onDismiss: (() -> Void)?
// MARK:
///
private enum ThemePreset: Int, CaseIterable {
case light
case yellow
@@ -60,20 +50,30 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
}
private let scrollView = UIScrollView()
private let contentStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 20
return stackView
}()
private let brightnessSlider = UISlider()
private let fontValueLabel = UILabel()
private let decreaseFontButton = UIButton(type: .system)
private let increaseFontButton = UIButton(type: .system)
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
private let themeStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
@@ -81,11 +81,11 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
stackView.spacing = 12
return stackView
}()
private var themeButtons: [UIButton] = []
/// 1.3 1.6 1.9
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
///
private var currentConfiguration: RDEPUBReaderConfiguration
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
@@ -1,37 +1,19 @@
import UIKit
// MARK: -
/// EPUB
///
/// 绿
public struct RDEPUBReaderTheme: Equatable {
// MARK:
///
public var contentBackgroundColor: UIColor
///
public var contentTextColor: UIColor
// MARK:
///
public var toolBackgroundColor: UIColor
/// /
public var toolControlTextColor: UIColor
///
public var toolControlBorderUnselectColor: UIColor
/// 线
public var toolLineColor: UIColor
///
/// - Parameters:
/// - contentBackgroundColor:
/// - contentTextColor:
/// - toolBackgroundColor:
/// - toolControlTextColor:
/// - toolControlBorderUnselectColor:
/// - toolLineColor: 线
public init(
contentBackgroundColor: UIColor,
contentTextColor: UIColor,
@@ -48,9 +30,6 @@ public struct RDEPUBReaderTheme: Equatable {
self.toolLineColor = toolLineColor
}
// MARK:
///
public static let light = RDEPUBReaderTheme(
contentBackgroundColor: .white,
contentTextColor: .black,
@@ -60,7 +39,6 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let dark = RDEPUBReaderTheme(
contentBackgroundColor: .black,
contentTextColor: .white,
@@ -70,7 +48,6 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let yellow = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
contentTextColor: .black,
@@ -80,7 +57,6 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
/// 绿绿
public static let green = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
contentTextColor: .black,
@@ -90,7 +66,6 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let pink = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
contentTextColor: .black,
@@ -100,7 +75,6 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let blue = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
contentTextColor: .black,
@@ -111,15 +85,12 @@ public struct RDEPUBReaderTheme: Equatable {
)
}
// MARK: - CSS
extension RDEPUBReaderTheme {
/// CSS EPUB HTML
var themeBackgroundColorCSS: String {
contentBackgroundColor.ss_cssString
}
/// CSS EPUB HTML
var themeTextColorCSS: String {
contentTextColor.ss_cssString
}
@@ -4,17 +4,14 @@ import UIKit
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText
///
final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot?
private var dtLayoutFrame: DTCoreTextLayoutFrame?
#if canImport(DTCoreText)
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
dtLayoutFrame = layoutFrame
if let layoutFrame, let page {
@@ -25,9 +22,6 @@ final class RDEPUBPageInteractionController {
}
#endif
// MARK: - Hit Testing
/// WXRead stringIndexForPoint:
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
return characterIndex(at: localPoint)
@@ -36,7 +30,6 @@ final class RDEPUBPageInteractionController {
func characterIndex(at point: CGPoint) -> Int? {
guard let snapshot else { return nil }
// Attachment rect priority (6pt inset for easier tapping)
for attachment in snapshot.attachments {
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
return attachment.stringRange.location
@@ -63,8 +56,6 @@ final class RDEPUBPageInteractionController {
#endif
}
// MARK: - Selection Range
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
guard let start = characterIndex(at: startPoint),
let end = characterIndex(at: endPoint) else { return nil }
@@ -73,8 +64,6 @@ final class RDEPUBPageInteractionController {
return NSRange(location: lower, length: max(upper - lower, 1))
}
// MARK: - Selection Rects
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
guard let snapshot else { return [] }
var rects: [CGRect] = []
@@ -136,8 +125,6 @@ final class RDEPUBPageInteractionController {
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
}
// MARK: - Caret Rect
func caretRect(at index: Int) -> CGRect? {
guard let snapshot else { return nil }
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
@@ -159,8 +146,6 @@ final class RDEPUBPageInteractionController {
)
}
// MARK: - Private Helpers
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
var bestLine: RDEPUBPageLine?
var bestDistance: CGFloat = .greatestFiniteMagnitude
@@ -196,6 +181,7 @@ final class RDEPUBPageInteractionController {
}
#if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame, let snapshot else { return nil }
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
@@ -4,63 +4,56 @@ import UIKit
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText 线
struct RDEPUBPageLine {
/// chapterContent
let stringRange: NSRange
///
let frame: CGRect
/// 线
let baselineOrigin: CGPoint
/// 线
let ascent: CGFloat
/// 线
let descent: CGFloat
///
let leading: CGFloat
}
/// Run
///
struct RDEPUBPageRun {
/// Run
let stringRange: NSRange
/// Run
let frame: CGRect
///
let isAttachment: Bool
}
///
///
struct RDEPUBPageAttachment {
///
let stringRange: NSRange
///
let frame: CGRect
///
let displaySize: CGSize
/// /
let placement: RDEPUBTextAttachmentPlacement?
/// /
let kind: RDEPUBTextAttachmentKind?
}
// MARK: -
///
/// DTCoreText
///
struct RDEPUBPageLayoutSnapshot {
let page: RDEPUBTextPage
let lines: [RDEPUBPageLine]
let runs: [RDEPUBPageRun]
let attachments: [RDEPUBPageAttachment]
let pageContentRange: NSRange
#if canImport(DTCoreText)
let layoutFrame: DTCoreTextLayoutFrame
#endif
@@ -128,6 +121,7 @@ struct RDEPUBPageLayoutSnapshot {
}
#if canImport(DTCoreText)
static func build(
from layoutFrame: DTCoreTextLayoutFrame,
page: RDEPUBTextPage
@@ -1,47 +1,43 @@
import UIKit
// MARK: -
///
///
struct RDEPUBTextOverlayDecoration {
///
enum Kind: String {
///
case selection
///
case highlight
/// 线
case underline
///
case search
///
case activeSearch
///
case locate
}
///
var kind: Kind
///
var absoluteRange: NSRange
///
var rects: [CGRect]
///
var color: UIColor
}
// MARK: -
///
///
/// 使 Core Graphics 线
class RDEPUBSelectionOverlayView: UIView {
private(set) var page: RDEPUBTextPage?
private var snapshot: RDEPUBPageLayoutSnapshot?
private(set) var selectionRange: NSRange?
private var selectionRects: [CGRect] = []
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let selectionVerticalAdjustment: CGFloat = -1
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
@@ -1,16 +1,11 @@
import UIKit
///
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
///
/// - Parameters:
/// - highlights:
/// - content:
/// - page:
/// - contentBaseOffset:
func applyHighlights(
_ highlights: [RDEPUBHighlight],
to content: NSMutableAttributedString,
@@ -47,12 +42,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
}
}
///
/// - Parameters:
/// - content:
/// - page:
/// - searchState:
/// - contentBaseOffset:
func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
@@ -78,13 +67,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
}
}
/// 线
/// - Parameters:
/// - page:
/// - highlights:
/// - searchState:
/// - interactionController:
/// - Returns:
func buildDecorations(
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight],
@@ -5,10 +5,9 @@ import Foundation
import DTCoreText
#endif
// MARK: -
protocol RDEPUBTextContentViewDelegate: AnyObject {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -27,22 +26,46 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
)
}
// MARK: -
/// EPUB
/// WXRead CoreText drawRect
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var currentChapterCFIMap: RDEPUBCFIMap?
private var currentChapterFragmentOffsets: [String: Int] = [:]
private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection?
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private let selectionLoupeView = RDEPUBSelectionLoupeView()
private var selectionInteractionState: SelectionInteractionState = .idle
private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
weak var delegate: RDEPUBTextContentViewDelegate?
var selectionTapSuppressionDidChange: ((Bool) -> Void)?
var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
#if canImport(DTCoreText)
private let coreTextContentView: RDEPUBTextPageRenderView = {
let view = RDEPUBTextPageRenderView()
view.backgroundColor = .clear
@@ -53,10 +76,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
#endif
private let interactionController = RDEPUBPageInteractionController()
private let selectionController = RDEPUBTextSelectionController()
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
@@ -91,7 +116,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.isEnabled = false
gesture.delegate = self
return gesture
}()
@@ -102,8 +126,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return gesture
}()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
@@ -114,10 +136,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
addSubview(overlayView)
addSubview(pageNumberLabel)
addSubview(selectionLoupeView)
addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer)
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
selectionLoupeView.isHidden = true
selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return }
@@ -130,17 +154,30 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.interactionStateDidChange = { [weak self] state in
self?.handleSelectionControllerStateChange(state)
}
selectionController.pageProvider = { [weak self] in self?.currentPage }
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
selectionController.chapterFragmentOffsetsProvider = { [weak self] in
self?.currentChapterFragmentOffsets ?? [:]
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - First Responder
override var canBecomeFirstResponder: Bool { true }
var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
longPressGestureRecognizer
}
var isSelectionInteractionInProgress: Bool {
selectionInteractionState != .idle
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
@@ -159,8 +196,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
performSelectionAction(.annotate)
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
@@ -181,19 +216,22 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
)
}
// MARK: - Configure
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
chapterCFIMap: RDEPUBCFIMap? = nil,
chapterFragmentOffsets: [String: Int] = [:],
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
currentPage = page
currentChapterCFIMap = chapterCFIMap
currentChapterFragmentOffsets = chapterFragmentOffsets
currentSelection = nil
menuSelection = nil
updateSelectionInteractionState(.idle)
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
@@ -228,7 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
value: configuration.theme.contentTextColor,
range: fullRange
)
// /线 WXRead WRChapterData.addHighlightInRange:
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
@@ -263,7 +301,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
panGestureRecognizer.isEnabled = false
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
selectionLoupeView.dismiss()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
@@ -271,8 +311,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
// MARK: -
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
let selection = currentSelection ?? menuSelection
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
@@ -280,8 +318,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
hideSelectionMenu()
}
// MARK: - Cover Image
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0,
page.href.lowercased().contains("cover"),
@@ -327,9 +363,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return nil
}
// MARK: - Dark Image Adjustment
#if canImport(DTCoreText)
private func darkImageAdjustedContentIfNeeded(
_ content: NSMutableAttributedString,
configuration: RDEPUBReaderConfiguration
@@ -409,8 +444,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
#endif
// MARK: - WXRead WRChapterData.addHighlightInRange:
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
@@ -435,8 +468,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
}
// MARK: - Content Normalization
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
@@ -463,9 +494,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return !CharacterSet.newlines.contains(previousScalar)
}
// MARK: - CoreText Layout
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden,
let displayContent = coreTextDisplayContent,
@@ -502,8 +532,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
#endif
// MARK: - Gestures
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
@@ -514,12 +542,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
)
switch gesture.state {
case .began:
panGestureRecognizer.isEnabled = true
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
hideSelectionMenu()
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .changed:
updateSelectionInteractionState(.selecting)
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .ended:
panGestureRecognizer.isEnabled = false
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
activeSelectionHandle = nil
selectionLoupeView.dismiss()
default:
break
}
@@ -528,17 +563,46 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
#if canImport(DTCoreText)
selectionController.handlePan(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
layoutIfNeeded()
let point = gesture.location(in: coreTextRenderView ?? self)
if gesture.state == .began, activeSelectionHandle == nil {
if let renderView = coreTextRenderView,
let handle = renderView.selectionHandle(at: point) {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
updateSelectionLoupe(for: point)
}
}
if let activeSelectionHandle, let renderView = coreTextRenderView {
selectionController.updateSelection(
byAdjusting: activeSelectionHandle,
at: point,
renderView: renderView,
interactionController: interactionController
)
updateSelectionLoupe(for: point)
} else {
selectionController.handlePan(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
if selectionController.isSelecting {
updateSelectionLoupe(for: point)
}
}
switch gesture.state {
case .ended:
panGestureRecognizer.isEnabled = false
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
default:
break
}
@@ -547,7 +611,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
if currentSelection != nil {
if let renderView = coreTextRenderView {
let renderPoint = gesture.location(in: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
return
}
if currentSelection != nil {
if renderView.selectionContains(renderPoint) {
showSelectionMenuIfNeeded()
return
}
clearSelection()
return
}
} else if currentSelection != nil {
clearSelection()
return
}
@@ -563,6 +640,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
return
}
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
@@ -589,6 +667,57 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
private func updateSelectionLoupe(for point: CGPoint) {
#if canImport(DTCoreText)
guard let renderView = coreTextRenderView else { return }
let localPoint = convert(point, from: renderView)
selectionLoupeView.present(
sourceView: renderView,
focusPoint: point,
hostBounds: bounds.inset(by: contentInsets),
targetPoint: localPoint
)
#endif
}
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if currentSelection == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = selectionInteractionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
selectionInteractionState = state
let currentTapSuppressed = selectionInteractionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
if previousTapSuppressed != currentTapSuppressed {
selectionTapSuppressionDidChange?(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
private var coreTextRenderView: RDEPUBTextPageRenderView? {
#if canImport(DTCoreText)
return coreTextContentView
@@ -678,23 +807,140 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return overlayView.convert(fallbackRect, to: self)
}
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
#if canImport(DTCoreText)
if selectionInteractionState == .selectionPending {
return true
}
guard let renderView = coreTextRenderView else {
return false
}
let renderPoint = convert(point, to: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
return true
}
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
return true
}
switch selectionInteractionState {
case .idle:
return false
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
return true
}
#else
return false
#endif
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
#if canImport(DTCoreText)
guard selectionInteractionState == .idle,
activeSelectionHandle == nil,
currentPage != nil,
let touch = touches.first,
let renderView = coreTextRenderView else {
return
}
let point = touch.location(in: renderView)
guard renderView.selectionHandle(at: point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
#endif
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
resetSelectionPendingIfNeeded()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
resetSelectionPendingIfNeeded()
}
private func resetSelectionPendingIfNeeded() {
guard selectionInteractionState == .selectionPending else { return }
if currentSelection != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return selectionController.isSelecting
if selectionController.isSelecting {
return true
}
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
let renderView = coreTextRenderView else {
return false
}
let point = pan.location(in: renderView)
return renderView.selectionHandle(at: point) != nil
}
if gestureRecognizer === longPressGestureRecognizer {
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
let renderView = coreTextRenderView else {
return true
}
let point = longPress.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
return false
}
return true
}
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
}
if selectionController.hasActiveSelection {
if let renderView = coreTextRenderView {
let point = tapGestureRecognizer.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
}
return true
}
let point = tapGestureRecognizer.location(in: overlayView)
return attachmentText(at: point) != nil || highlight(at: point) != nil
if attachmentText(at: point) != nil || highlight(at: point) != nil {
return true
}
return currentPage != nil
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
guard let renderView = coreTextRenderView else {
return true
}
let point = touch.location(in: renderView)
if let handle = renderView.selectionHandle(at: point) {
if gestureRecognizer === panGestureRecognizer {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
return true
}
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
return false
}
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
@@ -703,9 +949,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
}
// MARK: - UIColor Extension
private final class RDEPUBSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}
private extension UIColor {
var rd_isDarkReaderBackground: Bool {
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
@@ -1,5 +1,3 @@
import UIKit
///
/// RDEPUBSelectionOverlayView
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
@@ -3,10 +3,13 @@ import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
/// WXRead WRPageView drawRect
final class RDEPUBTextPageRenderView: UIView {
enum SelectionHandle {
case start
case end
}
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
@@ -19,26 +22,27 @@ final class RDEPUBTextPageRenderView: UIView {
}
}
/// attributed string com.rdreader.highlight
var attributedDisplayContent: NSAttributedString? {
didSet {
setNeedsDisplay()
}
}
// MARK: - WXRead _drawSelectionInContext:
/// RDEPUBTextSelectionController
var selectionRects: [CGRect] = [] {
didSet {
setNeedsDisplay()
}
}
/// WXRead
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
// MARK: - Init
private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
private let selectionHandleStemWidth: CGFloat = 2.5
private let selectionHandleKnobRadius: CGFloat = 7
private let selectionHandleHitSlop: CGFloat = 20
override init(frame: CGRect) {
super.init(frame: frame)
@@ -51,30 +55,23 @@ final class RDEPUBTextPageRenderView: UIView {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Draw
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
// 1. WXRead drawHighlightsInContext:
if let attributedDisplayContent {
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
}
// 2.
layoutFrame.draw(in: context, options: drawOptions)
// 3.
drawSelection(in: context)
context.restoreGState()
}
// MARK: - WXRead WRCoreTextLayoutFrame.drawHighlightsInContext:
private func drawHighlights(
in context: CGContext,
attributedString: NSAttributedString,
@@ -82,7 +79,6 @@ final class RDEPUBTextPageRenderView: UIView {
) {
let fullRange = NSRange(location: 0, length: attributedString.length)
//
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
@@ -92,7 +88,6 @@ final class RDEPUBTextPageRenderView: UIView {
}
}
// 线
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
@@ -107,7 +102,6 @@ final class RDEPUBTextPageRenderView: UIView {
}
}
/// WXRead rectsForRange:
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
var rects: [CGRect] = []
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
@@ -131,14 +125,110 @@ final class RDEPUBTextPageRenderView: UIView {
return rects
}
// MARK: -
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
drawSelectionHandles(in: context)
}
func selectionHandle(at point: CGPoint) -> SelectionHandle? {
guard let handleGeometry = selectionHandleGeometry else { return nil }
let startDistance = point.distance(to: handleGeometry.startKnobCenter)
let endDistance = point.distance(to: handleGeometry.endKnobCenter)
let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
let startMatched = startDistance <= maxDistance
let endMatched = endDistance <= maxDistance
switch (startMatched, endMatched) {
case (true, true):
return startDistance <= endDistance ? .start : .end
case (true, false):
return .start
case (false, true):
return .end
default:
return nil
}
}
func selectionContains(_ point: CGPoint) -> Bool {
selectionRects.contains { rect in
rect.insetBy(dx: -6, dy: -8).contains(point)
}
}
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
guard let firstRect = selectionRects.first,
let lastRect = selectionRects.last else {
return nil
}
let startKnobCenter = CGPoint(
x: firstRect.minX,
y: firstRect.minY - selectionHandleKnobRadius
)
let endKnobCenter = CGPoint(
x: lastRect.maxX,
y: lastRect.maxY + selectionHandleKnobRadius
)
return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
}
private func drawSelectionHandles(in context: CGContext) {
guard let firstRect = selectionRects.first,
let lastRect = selectionRects.last else {
return
}
context.saveGState()
context.setFillColor(selectionHandleColor.cgColor)
let stemHalfWidth = selectionHandleStemWidth / 2
let startStem = CGRect(
x: firstRect.minX - stemHalfWidth,
y: firstRect.minY - selectionHandleKnobRadius * 2,
width: selectionHandleStemWidth,
height: firstRect.height + selectionHandleKnobRadius * 2
)
context.fill(startStem)
let startKnob = CGRect(
x: firstRect.minX - selectionHandleKnobRadius,
y: firstRect.minY - selectionHandleKnobRadius * 2,
width: selectionHandleKnobRadius * 2,
height: selectionHandleKnobRadius * 2
)
context.fillEllipse(in: startKnob)
let endStem = CGRect(
x: lastRect.maxX - stemHalfWidth,
y: lastRect.minY,
width: selectionHandleStemWidth,
height: lastRect.height + selectionHandleKnobRadius * 2
)
context.fill(endStem)
let endKnob = CGRect(
x: lastRect.maxX - selectionHandleKnobRadius,
y: lastRect.maxY,
width: selectionHandleKnobRadius * 2,
height: selectionHandleKnobRadius * 2
)
context.fillEllipse(in: endKnob)
context.restoreGState()
}
}
private extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
hypot(x - point.x, y - point.y)
}
}
#endif
@@ -1,18 +1,51 @@
import UIKit
/// CoreText
/// WXRead
final class RDEPUBTextSelectionController: NSObject {
enum BoundaryHandle {
case start
case end
}
enum InteractionState: Equatable {
case idle
case selecting
case selectionActive
case adjustingHandle
}
private enum SelectionGranularity {
case character
case word
}
private(set) var isSelecting = false
private var selectionStartIndex: Int = NSNotFound
private var selectionEndIndex: Int = NSNotFound
/// UI
private var activeGranularity: SelectionGranularity = .character
private var selectionAnchorIndex: Int = NSNotFound
private(set) var interactionState: InteractionState = .idle {
didSet {
guard interactionState != oldValue else { return }
interactionStateDidChange?(interactionState)
}
}
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
///
var interactionStateDidChange: ((InteractionState) -> Void)?
var pageProvider: (() -> RDEPUBTextPage?)?
var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)?
var chapterFragmentOffsetsProvider: (() -> [String: Int])?
var hasActiveSelection: Bool {
selectedAbsoluteRange != nil
}
@@ -37,11 +70,14 @@ final class RDEPUBTextSelectionController: NSObject {
switch gesture.state {
case .began:
setInteractionState(.selecting)
beginSelection(at: point, renderView: renderView, interactionController: interactionController)
case .changed:
setInteractionState(.selecting)
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
case .ended:
isSelecting = false
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
case .cancelled, .failed:
clearSelection(renderView: renderView)
default:
@@ -59,14 +95,46 @@ final class RDEPUBTextSelectionController: NSObject {
switch gesture.state {
case .began, .changed:
setInteractionState(.selecting)
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
case .ended, .cancelled, .failed:
isSelecting = false
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
default:
break
}
}
func updateSelection(
byAdjusting handle: BoundaryHandle,
at point: CGPoint,
renderView: RDEPUBTextPageRenderView?,
interactionController: RDEPUBPageInteractionController
) {
guard let renderView,
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView),
selectedAbsoluteRange != nil else {
return
}
setInteractionState(.adjustingHandle)
switch handle {
case .start:
selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start)
if selectionEndIndex != NSNotFound {
selectionStartIndex = min(selectionStartIndex, selectionEndIndex)
}
case .end:
selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end)
if selectionStartIndex != NSNotFound {
selectionEndIndex = max(selectionEndIndex, selectionStartIndex)
}
}
applySelection(renderView: renderView, interactionController: interactionController)
}
func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
guard let absoluteRange = selectedAbsoluteRange else { return nil }
return interactionController.menuAnchorRect(for: absoluteRange)
@@ -74,8 +142,11 @@ final class RDEPUBTextSelectionController: NSObject {
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
isSelecting = false
selectionAnchorIndex = NSNotFound
activeGranularity = .character
selectionStartIndex = NSNotFound
selectionEndIndex = NSNotFound
setInteractionState(.idle)
renderView?.selectionRects = []
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
@@ -92,9 +163,141 @@ final class RDEPUBTextSelectionController: NSObject {
}
return
}
selectionStartIndex = index
selectionEndIndex = index
selectionAnchorIndex = index
activeGranularity = .word
isSelecting = true
applySelection(
range: selectionRange(for: index, granularity: .word),
renderView: renderView,
interactionController: interactionController
)
}
private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? {
guard let page = pageProvider?() else { return nil }
switch granularity {
case .character:
return NSRange(location: focusIndex, length: 1)
case .word:
if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) {
return wordRange
}
return NSRange(location: focusIndex, length: 1)
}
}
private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int {
guard let page = pageProvider?() else { return index }
let text = page.chapterContent.string as NSString
guard let wordRange = wordRange(containing: index, in: text) else {
return index
}
switch handle {
case .start:
return wordRange.location
case .end:
return max(wordRange.location + wordRange.length - 1, wordRange.location)
}
}
private func wordRange(containing index: Int, in text: NSString) -> NSRange? {
guard text.length > 0 else { return nil }
let safeIndex = min(max(index, 0), max(text.length - 1, 0))
if let scalar = UnicodeScalar(text.character(at: safeIndex)),
CharacterSet.whitespacesAndNewlines.contains(scalar) {
return nearestWordRange(to: safeIndex, in: text)
?? text.rangeOfComposedCharacterSequence(at: safeIndex)
}
let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex)
let probeRange = NSRange(location: safeIndex, length: 1)
var matchedWordRange: NSRange?
text.enumerateSubstrings(
in: NSRange(location: 0, length: text.length),
options: [.byWords, .substringNotRequired]
) { _, substringRange, _, stop in
guard substringRange.length > 0 else { return }
if NSIntersectionRange(substringRange, probeRange).length > 0
|| NSLocationInRange(characterRange.location, substringRange) {
matchedWordRange = substringRange
stop.pointee = true
}
}
if let matchedWordRange {
let trimmedRange = trimmed(range: matchedWordRange, in: text)
if trimmedRange.length > 0 {
return trimmedRange
}
}
return characterRange
}
private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? {
var nearestRange: NSRange?
var nearestDistance = Int.max
text.enumerateSubstrings(
in: NSRange(location: 0, length: text.length),
options: [.byWords, .substringNotRequired]
) { _, substringRange, _, _ in
guard substringRange.length > 0 else { return }
let trimmedRange = self.trimmed(range: substringRange, in: text)
guard trimmedRange.length > 0 else { return }
let distance: Int
if index < trimmedRange.location {
distance = trimmedRange.location - index
} else if index >= trimmedRange.location + trimmedRange.length {
distance = index - (trimmedRange.location + trimmedRange.length - 1)
} else {
distance = 0
}
if distance < nearestDistance {
nearestDistance = distance
nearestRange = trimmedRange
}
}
return nearestRange
}
private func trimmed(range: NSRange, in text: NSString) -> NSRange {
guard range.length > 0 else { return range }
var lowerBound = range.location
var upperBound = range.location + range.length
while lowerBound < upperBound,
let scalar = UnicodeScalar(text.character(at: lowerBound)),
CharacterSet.whitespacesAndNewlines.contains(scalar) {
lowerBound += 1
}
while upperBound > lowerBound,
let scalar = UnicodeScalar(text.character(at: upperBound - 1)),
CharacterSet.whitespacesAndNewlines.contains(scalar) {
upperBound -= 1
}
return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0))
}
private func applySelection(
range: NSRange?,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let range, range.location != NSNotFound, range.length > 0 else {
clearSelection(renderView: renderView)
return
}
selectionStartIndex = range.location
selectionEndIndex = range.location + range.length - 1
applySelection(renderView: renderView, interactionController: interactionController)
}
@@ -103,11 +306,24 @@ final class RDEPUBTextSelectionController: NSObject {
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard selectionStartIndex != NSNotFound,
guard selectionAnchorIndex != NSNotFound,
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
return
}
selectionEndIndex = index
let clampedIndex: Int
switch activeGranularity {
case .character:
clampedIndex = index
case .word:
clampedIndex = snappedBoundaryIndex(
for: index,
handle: index >= selectionAnchorIndex ? .end : .start
)
}
selectionStartIndex = selectionAnchorIndex
selectionEndIndex = clampedIndex
applySelection(renderView: renderView, interactionController: interactionController)
}
@@ -122,9 +338,14 @@ final class RDEPUBTextSelectionController: NSObject {
}
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
setInteractionState(isSelecting ? .selecting : .selectionActive)
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
}
private func setInteractionState(_ state: InteractionState) {
interactionState = state
}
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
guard absoluteRange.location != NSNotFound,
absoluteRange.length > 0,
@@ -137,31 +358,32 @@ final class RDEPUBTextSelectionController: NSObject {
return nil
}
let totalLength = max(page.chapterContent.length - 1, 1)
let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length
let startAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalStart
)
let endAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalEnd
)
let chapterData = makeChapterData(for: page)
let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil,
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
),
location: location,
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
)
}
private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: page.chapterIndex,
spineIndex: page.spineIndex,
href: page.href,
title: page.chapterTitle,
attributedContent: page.chapterContent,
fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:],
cfiMap: chapterCFIMapProvider?(),
pageBreakReasons: [],
pages: [page]
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
@@ -1,11 +1,7 @@
import UIKit
/// UIColor HEX 便
extension UIColor {
///
/// - Parameters:
/// - rdHexString: HEX "#" "#FF5733" "FF5733" 6
/// - alpha: 0.0 ~ 1.0
convenience init?(rdHexString: String, alpha: CGFloat) {
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")