refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs

- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 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?()
}
}
}
@@ -0,0 +1,36 @@
import UIKit
final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tapGesture.cancelsTouchesInView = false
addGestureRecognizer(tapGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let tooltipView else {
onBackgroundTap?()
return
}
if !tooltipView.frame.contains(point) {
onBackgroundTap?()
}
}
}
@@ -0,0 +1,188 @@
import UIKit
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
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: topInset,
left: contentInsets.left,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close()
return path
}
}
@@ -0,0 +1,241 @@
import UIKit
final class RDEPUBImageViewController: UIViewController {
struct Configuration {
let image: UIImage
let sourceRect: CGRect?
let altText: String?
let theme: RDEPUBReaderTheme
init(image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil, theme: RDEPUBReaderTheme) {
self.image = image
self.sourceRect = sourceRect
self.altText = altText
self.theme = theme
}
}
// MARK: - Properties
private let configuration: Configuration
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let closeButton = UIButton(type: .system)
private let backgroundView = UIView()
private var isShowingChrome = true
// MARK: - Init
init(configuration: Configuration) {
self.configuration = configuration
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupBackground()
setupScrollView()
setupImageView()
setupCloseButton()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
centerImage()
}
override var prefersStatusBarHidden: Bool { true }
// MARK: - Setup
private func setupBackground() {
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.backgroundColor = configuration.theme.imageViewerBackgroundColor
view.addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissViewer))
backgroundView.addGestureRecognizer(tap)
}
private func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 5.0
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
scrollView.decelerationRate = .fast
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissViewer))
swipeDown.direction = .down
scrollView.addGestureRecognizer(swipeDown)
}
private func setupImageView() {
imageView.image = configuration.image
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
imageView.accessibilityLabel = configuration.altText
imageView.isAccessibilityElement = configuration.altText != nil
scrollView.addSubview(imageView)
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
imageView.addGestureRecognizer(doubleTap)
let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleChrome))
singleTap.numberOfTapsRequired = 1
singleTap.require(toFail: doubleTap)
imageView.addGestureRecognizer(singleTap)
}
private func setupCloseButton() {
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal)
closeButton.tintColor = .white
closeButton.backgroundColor = UIColor(white: 0.3, alpha: 0.6)
closeButton.layer.cornerRadius = 16
closeButton.clipsToBounds = true
closeButton.addTarget(self, action: #selector(dismissViewer), for: .touchUpInside)
closeButton.accessibilityLabel = NSLocalizedString("Close image viewer", comment: "Accessibility label for close button in image viewer")
view.addSubview(closeButton)
closeButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32)
])
}
// MARK: - Layout
private func centerImage() {
guard let image = imageView.image else { return }
let boundsSize = scrollView.bounds.size
guard boundsSize.width > 0, boundsSize.height > 0 else { return }
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return }
let widthRatio = boundsSize.width / imageSize.width
let heightRatio = boundsSize.height / imageSize.height
let fitScale = min(widthRatio, heightRatio)
let fitWidth = imageSize.width * fitScale
let fitHeight = imageSize.height * fitScale
imageView.frame = CGRect(
x: 0,
y: 0,
width: fitWidth,
height: fitHeight
)
scrollView.contentSize = imageView.frame.size
let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
scrollView.contentInset = UIEdgeInsets(
top: verticalInset,
left: horizontalInset,
bottom: verticalInset,
right: horizontalInset
)
}
// MARK: - Actions
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > scrollView.minimumZoomScale {
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
} else {
let point = gesture.location(in: imageView)
let zoomSize = CGSize(width: 100, height: 100)
let zoomRect = CGRect(
x: point.x - zoomSize.width / 2,
y: point.y - zoomSize.height / 2,
width: zoomSize.width,
height: zoomSize.height
)
scrollView.zoom(to: zoomRect, animated: true)
}
}
@objc private func toggleChrome() {
isShowingChrome.toggle()
UIView.animate(withDuration: 0.25) {
self.closeButton.alpha = self.isShowingChrome ? 1.0 : 0.0
}
}
@objc private func dismissViewer() {
dismiss(animated: true)
}
}
// MARK: - UIScrollViewDelegate
extension RDEPUBImageViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
guard let image = imageView.image else { return }
let boundsSize = scrollView.bounds.size
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return }
let widthRatio = boundsSize.width / imageSize.width
let heightRatio = boundsSize.height / imageSize.height
let fitScale = min(widthRatio, heightRatio)
let fitWidth = imageSize.width * fitScale * scrollView.zoomScale
let fitHeight = imageSize.height * fitScale * scrollView.zoomScale
imageView.frame = CGRect(
x: 0,
y: 0,
width: fitWidth,
height: fitHeight
)
let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
scrollView.contentInset = UIEdgeInsets(
top: verticalInset,
left: horizontalInset,
bottom: verticalInset,
right: horizontalInset
)
}
}
@@ -0,0 +1,61 @@
import UIKit
final class RDEPUBImageViewerCoordinator {
private weak var presentingController: UIViewController?
private let theme: RDEPUBReaderTheme
init(presentingController: UIViewController, theme: RDEPUBReaderTheme) {
self.presentingController = presentingController
self.theme = theme
}
// MARK: - From Native Text Path (UIImage already resolved)
func presentImage(_ image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil) {
let config = RDEPUBImageViewController.Configuration(
image: image,
sourceRect: sourceRect,
altText: altText,
theme: theme
)
let viewerVC = RDEPUBImageViewController(configuration: config)
presentingController?.present(viewerVC, animated: true)
}
// MARK: - From WebView Path (need to resolve from src URL)
func presentImageFromWebView(
src: String,
baseHref: String?,
resourceResolver: RDEPUBResourceResolver
) {
guard let image = loadImage(src: src, baseHref: baseHref, resourceResolver: resourceResolver) else {
return
}
presentImage(image)
}
// MARK: - Private
private func loadImage(
src: String,
baseHref: String?,
resourceResolver: RDEPUBResourceResolver
) -> UIImage? {
let pathPart = src.components(separatedBy: "#").first ?? src
guard !pathPart.isEmpty else { return nil }
let fileURL: URL?
if let baseHref {
fileURL = resourceResolver.fileURL(forReference: pathPart, relativeToHref: baseHref)
} else {
let normalized = resourceResolver.normalizedHref(pathPart)
fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) }
}
// provider
guard let fileURL, let data = resourceResolver.resourceData(at: fileURL) else { return nil }
return UIImage(data: data)
}
}
@@ -0,0 +1,146 @@
import UIKit
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol {
var onShowTableOfContents: (() -> Void)?
var onShowBookmarks: (() -> Void)?
var onShowHighlights: (() -> Void)?
var onAddHighlight: (() -> Void)?
var onShowSettings: (() -> Void)?
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.distribution = .fillEqually
view.alignment = .fill
view.spacing = 16
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)
stackView.addArrangedSubview(bookmarksButton)
stackView.addArrangedSubview(highlightsButton)
stackView.addArrangedSubview(addHighlightButton)
stackView.addArrangedSubview(settingsButton)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
])
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
chapterButton.accessibilityIdentifier = "epub.reader.toc"
bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks"
highlightsButton.accessibilityIdentifier = "epub.reader.highlights"
addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight"
settingsButton.accessibilityIdentifier = "epub.reader.settings"
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
}
chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside)
bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside)
highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside)
addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: 0.5)
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
button.tintColor = .black
button.setTitleColor(.black, for: .normal)
}
}
func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45
}
func setHighlightsEnabled(_ isEnabled: Bool) {
highlightsButton.isEnabled = isEnabled
highlightsButton.alpha = isEnabled ? 1 : 0.45
}
func setBookmarksEnabled(_ isEnabled: Bool) {
bookmarksButton.isEnabled = isEnabled
bookmarksButton.alpha = isEnabled ? 1 : 0.45
}
func updateVisibility(
showsTableOfContents: Bool,
allowsHighlights: Bool,
showsSettingsPanel: Bool
) {
chapterButton.isHidden = !showsTableOfContents
bookmarksButton.isHidden = false
highlightsButton.isHidden = !allowsHighlights
addHighlightButton.isHidden = !allowsHighlights
settingsButton.isHidden = !showsSettingsPanel
}
private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) {
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) {
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
button.setTitle(fallbackTitle, for: .normal)
}
}
@objc private func chapterAction() {
onShowTableOfContents?()
}
@objc private func bookmarksAction() {
onShowBookmarks?()
}
@objc private func highlightsAction() {
onShowHighlights?()
}
@objc private func highlightAction() {
onAddHighlight?()
}
@objc private func settingsAction() {
onShowSettings?()
}
}
@@ -0,0 +1,66 @@
import UIKit
final class RDEPUBReaderChapterListController: UITableViewController {
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]
private let currentItem: RDEPUBReaderTableOfContentsItem?
private let theme: RDEPUBReaderTheme
init(
items: [RDEPUBReaderTableOfContentsItem],
currentItem: RDEPUBReaderTableOfContentsItem?,
theme: RDEPUBReaderTheme
) {
self.items = items
self.currentItem = currentItem
self.theme = theme
super.init(style: .plain)
title = "目录"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.toc.panel"
tableView.accessibilityIdentifier = "epub.reader.toc.table"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.tableFooterView = UIView(frame: .zero)
tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = items[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.backgroundColor = theme.contentBackgroundColor
cell.textLabel?.numberOfLines = 2
cell.textLabel?.text = item.title
cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
cell.indentationLevel = item.depth
cell.indentationWidth = 18
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
onSelectItem?(items[indexPath.row])
}
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
guard let currentItem else { return false }
return currentItem.href == item.href
}
}
@@ -0,0 +1,79 @@
import UIKit
extension RDEPUBReaderController {
func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.onBackgroundTap = { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0
let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
let anchorPoint = CGPoint(
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
)
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let topSafeY = view.safeAreaInsets.top + 12
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
let availableSpaceAbove = anchorRect.minY - topSafeY
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
let originY: CGFloat
switch tooltipPlacement {
case .bottom:
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
case .top:
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
}
let arrowTipX = min(
max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
UIView.animate(withDuration: 0.2) {
tooltip.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
}
private func hideAttachmentTooltipIfNeeded() {
view.subviews
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
.forEach { $0.removeFromSuperview() }
}
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
UIView.animate(withDuration: 0.18, animations: {
overlay.tooltipView?.alpha = 0
}, completion: { _ in
overlay.removeFromSuperview()
})
}
}
@@ -0,0 +1,185 @@
import UIKit
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
return
}
guard readerView.pageContentView(pageNum: readerView.currentPage) === contentView else {
return
}
let currentPage = activePages[readerView.currentPage]
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
return
}
persist(location: location)
readingSession?.updateReadingContext(
pageNumber: readerView.currentPage + 1,
location: location,
spineIndex: spineIndex,
chapterIndex: currentPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
} else {
updateCurrentSelection(nil)
}
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
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,
relativeToSpineIndex: fromSpineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
#endif
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
guard let publication else { return }
let baseHref = contentView.currentHref
let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
coordinator.presentImageFromWebView(src: src, baseHref: baseHref, resourceResolver: publication.resourceResolver)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
guard !altText.isEmpty else { return }
let rect = sourceRect ?? .zero
let sourcePoint = rect.isNull ? CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) : CGPoint(x: rect.midX, y: rect.midY)
presentAttachmentTooltip(text: altText, sourceView: contentView, sourceRect: rect, sourcePoint: sourcePoint)
}
}
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
return
}
updateCurrentSelection(selection)
}
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
RDEpubReaderTapDebug.log(
"ReaderController.textContentTap",
"delegate received reader tap from contentView=\(RDEpubReaderTapDebug.describe(contentView)) point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)"
)
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
) {
handleSelectionMenuAction(action, selection: selection ?? currentSelection)
contentView.clearSelection()
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect,
sourcePoint: CGPoint
) {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
) {
let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
coordinator.presentImage(image, sourceRect: sourceRect, altText: altText)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
) {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
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)
}
}
@@ -0,0 +1,456 @@
import UIKit
extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate {
public func numberOfPages(in readerView: RDEpubReaderView) -> Int {
effectiveNumberOfPages
}
public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
}
public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
pageIdentifier(readerView: readerView, pageNum: index)
}
public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
topToolView(readerView: readerView)
}
public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? {
bottomToolView(readerView: readerView)
}
public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int {
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
}
public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView {
// 宿
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
return makeTrialWallPageView(reusableView: containerView)
}
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: false
)
var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
let shouldAllowSynchronousFallback = (
readerView.currentPage < 0 || readerView.currentPage == pageNum
) && !readerView.isPageCurlTransitioning
if resolvedPage == nil, shouldAllowSynchronousFallback {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: true
)
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
}
if let resolvedPage {
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),
displayCache: textDisplayCache
)
return contentView
}
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.configureLoading(
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration
)
return contentView
}
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),
displayCache: textDisplayCache
)
return contentView
}
guard let publication,
let request = request(for: pageNum) else {
return containerView ?? UIView()
}
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
contentView.delegate = self
contentView.configure(
publication: publication,
request: request,
pageNumber: pageNum + 1,
totalPages: activePages.count,
theme: configuration.theme
)
return contentView
}
public func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String? {
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
return NSStringFromClass(RDEPUBTrialWallContainerView.self)
}
return (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) {
return chapterData.highlights(on: page, from: activeHighlights)
}
guard let publication else {
return activeHighlights.filter { $0.location.href == page.href }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
let matches: [RDEPUBSearchMatch]
let currentMatchIndex: Int?
if let chapterData = chapterData(for: page),
let resolvedState = resolvedSearchState(
for: page,
chapterData: chapterData,
globalSearchState: globalSearchState
) {
matches = resolvedState.matches
currentMatchIndex = resolvedState.currentMatchIndex
} else {
matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
}
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
}
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: matches,
currentMatchIndex: currentMatchIndex
)
}
private func resolvedSearchState(
for page: RDEPUBTextPage,
chapterData: RDEPUBChapterData,
globalSearchState: RDEPUBSearchState
) -> RDEPUBSearchState? {
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let normalizedHref = normalizedPageHref(for: page)
let exactMatches = exactChapterSearchMatches(
in: chapterData,
keyword: normalizedKeyword,
normalizedHref: normalizedHref
)
let pageMatches = exactMatches.filter { match in
guard let rangeLocation = match.rangeLocation else { return false }
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
return NSIntersectionRange(range, page.contentRange).length > 0
}
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
}
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: pageMatches,
currentMatchIndex: currentMatchIndex
)
}
private func exactChapterSearchMatches(
in chapterData: RDEPUBChapterData,
keyword: String,
normalizedHref: String
) -> [RDEPUBSearchMatch] {
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
var matches: [RDEPUBSearchMatch] = []
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return matches
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData
}
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
return nil
}
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
if let textBook,
let chapterData = textBook.chapterData(for: page.href),
let range = chapterData.absoluteRange(for: searchMatch) {
return NSIntersectionRange(range, page.contentRange).length > 0
}
let pageHref = normalizedPageHref(for: page)
let matchHref = normalizedSearchHref(searchMatch.href)
guard pageHref == matchHref else { return false }
guard let rangeLocation = searchMatch.rangeLocation else {
return false
}
let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
return NSIntersectionRange(range, page.contentRange).length > 0
}
private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
guard let publication else { return page.href }
return publication.resourceResolver.normalizedHref(page.href) ?? page.href
}
private func normalizedSearchHref(_ href: String) -> String {
guard let publication else { return href }
return publication.resourceResolver.normalizedHref(href) ?? href
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
}
public func topToolView(readerView: RDEpubReaderView) -> UIView? {
topToolView
}
public func bottomToolView(readerView: RDEpubReaderView) -> UIView? {
bottomToolView
}
public func pageNum(readerView: RDEpubReaderView, pageNum: Int) {
RDEPUBMemoryProbe.logPageTurn()
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
guard !isRepaginating else { return }
let previousCurrentPage = readerView.currentPage
let wasPageCurlTransitioning = readerView.isPageCurlTransitioning
runtime.applyPendingFullPageMapIfNeeded()
if wasPageCurlTransitioning {
DispatchQueue.main.async { [weak self, weak readerView] in
guard let self, let readerView, !readerView.isPageCurlTransitioning else { return }
self.runtime.applyPendingFullPageMapIfNeeded()
}
}
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
return
}
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: effectivePageNum + 1,
allowSynchronousLoad: false
)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
}
// location/CFI
if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex {
delegate?.epubReaderDidReachTrialWall(self)
return
}
let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, effectivePageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self)
}
if (textBook != nil || readerContext.bookPageMap != nil),
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
runtime.locationCoordinator.recordPageChangeIfNeeded()
return
}
if let location = fallbackLocation(for: effectivePageNum) {
persist(location: location)
}
runtime.locationCoordinator.recordPageChangeIfNeeded()
}
public func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) {
_ = isLandscape
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
}
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating,
!isReconcilingTextPaginationSize,
pageNum >= 0,
let lastTextPaginationPageSize else {
return
}
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
guard resolvedSize.width > 0,
resolvedSize.height > 0 else {
return
}
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
guard sizeChanged else {
return
}
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 }
guard let previousPageSize = self.lastTextPaginationPageSize else { return }
let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
|| abs(currentSize.height - previousPageSize.height) > 0.5
guard stillChanged else { return }
self.repaginatePreservingCurrentLocation()
}
}
}
@@ -0,0 +1,35 @@
import UIKit
extension RDEPUBReaderController {
func openExternalURLIfAllowed(_ url: URL) {
guard shouldAllowExternalURL(url) else { return }
if configuration.requiresExternalLinkConfirmation {
presentExternalLinkConfirmation(for: url)
} else {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
return false
}
return configuration.allowedExternalURLSchemes.contains(scheme)
}
private func presentExternalLinkConfirmation(for url: URL) {
let alert = UIAlertController(
title: "打开外部链接",
message: url.absoluteString,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
})
present(alert, animated: true)
}
}
@@ -0,0 +1,319 @@
import UIKit
extension RDEPUBReaderController {
func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex = resolvedLocalPageIndex(
for: normalizedLocation,
spineIndex: spineIndex,
fallbackEntry: entry,
rangeInfo: rangeInfo
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook {
let normalizedLocation = publication?.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) {
return pageNumber
}
if let anchor = normalizedLocation.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
if let publication {
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
// External text books have no resolver; hrefs match verbatim.
return textBook.chapterData(for: normalizedLocation.href)?
.pageNumber(for: normalizedLocation)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
func currentVisibleRangeInfo() -> String? {
guard let pageNumber = currentPageNumber else {
return nil
}
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset + 1, resolvedPage.page.pageEndOffset + 1)
return RDEPUBTextOffsetRangeInfo(
href: resolvedPage.chapter.href,
start: startOffset,
end: endOffset
).jsonString()
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let startOffset = page.pageStartOffset
let endOffset = max(startOffset + 1, page.pageEndOffset + 1)
return RDEPUBTextOffsetRangeInfo(
href: page.href,
start: startOffset,
end: endOffset
).jsonString()
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int {
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
return decodedRangeInfo.start
}
// Native text anchors carry the exact UTF-16 chapter offset and remain
// stable across font/layout changes. Prefer them over CFI recovery,
// which may temporarily resolve to the chapter's first marker while a
// new CFI map is still being prepared.
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let cfi = primaryLocationCFI(for: location),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: cfi) {
return offset
}
if let rawCFI = primaryLocationCFI(for: location),
let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
private func resolvedLocalPageIndex(
for location: RDEPUBLocation,
spineIndex: Int,
fallbackEntry: RDEPUBBookPageMapEntry,
rangeInfo: String? = nil
) -> Int? {
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo)
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
return pageIndex
}
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
return summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
}
}
return nil
}
private func primaryLocationCFI(for location: RDEPUBLocation) -> String? {
if let rangeCFI = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI) {
return rangeCFI.start.rawValue
}
return location.cfi
}
private func pageNumberFromRangeInfo(
_ rangeInfo: String?,
in textBook: RDEPUBTextBook,
matching location: RDEPUBLocation
) -> Int? {
guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location),
let chapterData = chapterData(in: textBook, for: decodedRangeInfo),
let page = chapterData.page(containing: decodedRangeInfo.start) else {
return nil
}
return page.absolutePageIndex + 1
}
private func decodedRangeInfo(
from rangeInfo: String?,
matching location: RDEPUBLocation
) -> RDEPUBTextOffsetRangeInfo? {
guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else {
return nil
}
return decodedRangeInfo
}
private func chapterData(
in textBook: RDEPUBTextBook,
for rangeInfo: RDEPUBTextOffsetRangeInfo
) -> RDEPUBChapterData? {
if let chapterData = textBook.chapterData(for: rangeInfo.href) {
return chapterData
}
let targetHref = normalizedHref(rangeInfo.href)
if let chapterData = textBook.chapterData(for: targetHref) {
return chapterData
}
guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else {
return nil
}
return textBook.chapterData(for: chapter.href)
}
private func normalizedHref(_ href: String) -> String {
publication?.resourceResolver.normalizedHref(href)
?? href.components(separatedBy: "#").first
?? href
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
private func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
@@ -0,0 +1,48 @@
import UIKit
///
///
/// `publication.metadata.orientation` OPF rendition:orientation
/// iBooks display-options
/// nil / .auto
///
/// `supportedInterfaceOrientations`
/// 宿 present
extension RDEPUBReaderController {
/// EPUB nil =
var epubSupportedOrientationMask: UIInterfaceOrientationMask? {
switch publication?.metadata.orientation {
case .portrait:
return .portrait
case .landscape:
return .landscape
case .auto, .none:
return nil
}
}
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
epubSupportedOrientationMask ?? super.supportedInterfaceOrientations
}
public override var shouldAutorotate: Bool {
//
epubSupportedOrientationMask == nil
}
/// iOS 16+ requestGeometryUpdate退
///
func applyOrientationLockIfNeeded() {
guard let mask = epubSupportedOrientationMask else { return }
if #available(iOS 16.0, *) {
setNeedsUpdateOfSupportedInterfaceOrientations()
guard let scene = view.window?.windowScene else { return }
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
} else {
let target: UIInterfaceOrientation = (mask == .landscape) ? .landscapeRight : .portrait
UIDevice.current.setValue(target.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
@@ -0,0 +1,168 @@
import UIKit
extension RDEPUBReaderController {
public func reloadBook() {
runtime.reloadBook()
}
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
_ = runtime.go(to: location)
}
@discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
runtime.go(toPageNumber: pageNumber, animated: animated)
}
public func clearSelection() {
runtime.clearSelection()
}
public func bookmark(withID id: String) -> RDEPUBBookmark? {
runtime.bookmark(withID: id)
}
public func highlight(withID id: String) -> RDEPUBHighlight? {
runtime.highlight(withID: id)
}
public func nativeTextSemanticSummary() -> String? {
let resolvedPage: RDEPUBTextPage?
if let textBook {
resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first
} else if readerContext.bookPageMap != nil {
let absolutePageIndex = max(readerView.currentPage, 0)
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1)
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page
} else {
resolvedPage = nil
}
guard let page = resolvedPage else {
return nil
}
let metadata = page.metadata
var parts = [
"page \(page.absolutePageIndex + 1)",
"break \(metadata.breakReason.rawValue)",
metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]",
metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]",
metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let firstDiagnostic = metadata.diagnostics.first {
parts.append(firstDiagnostic)
}
return parts.joined(separator: " · ")
}
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
runtime.addHighlight(from: selection, color: color, note: note)
}
@discardableResult
public func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
}
@discardableResult
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
runtime.upsertHighlight(highlight)
}
@discardableResult
public func removeHighlight(id: String) -> RDEPUBHighlight? {
runtime.removeHighlight(id: id)
}
@discardableResult
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
runtime.updateHighlightNote(id: id, note: note)
}
@discardableResult
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
runtime.go(toHighlightID: id, animated: animated)
}
public func removeAllHighlights() {
runtime.removeAllHighlights()
}
@discardableResult
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
guard publication != nil else { return false }
let components = href.components(separatedBy: "#")
let baseHref = components.first ?? href
let fragment = components.count > 1 ? components[1] : nil
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: baseHref,
progression: 0,
fragment: fragment
)
return restoreReadingLocation(location, animated: animated)
}
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.addBookmark(note: note)
}
@discardableResult
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.toggleBookmark(note: note)
}
@discardableResult
public func removeBookmark(id: String) -> RDEPUBBookmark? {
runtime.removeBookmark(id: id)
}
@discardableResult
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
runtime.go(toBookmarkID: id, animated: animated)
}
public func search(keyword: String) {
runtime.search(keyword: keyword)
}
@discardableResult
public func searchNext() -> Bool {
runtime.searchNext()
}
@discardableResult
public func searchPrevious() -> Bool {
runtime.searchPrevious()
}
public func clearSearch() {
runtime.clearSearch()
}
}
@@ -0,0 +1,92 @@
import UIKit
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)
if paginationHostView.superview == nil {
view.addSubview(paginationHostView)
view.sendSubviewToBack(paginationHostView)
}
paginationHostView.frame = hostFrame
return paginationHostView
}
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
guard let publication, activePages.indices.contains(pageIndex) else {
return nil
}
let page = activePages[pageIndex]
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
forPageNumber: pageIndex + 1,
spineIndex: page.spineIndex
)
var preferences = currentPreferences()
if publication.layout == .fixed, readerView.pagesPerScreen == 2 {
// 5pt 10pt
preferences.fixedContentInset.left += 5
preferences.fixedContentInset.right += 5
}
return preferences.renderRequest(
for: page,
publication: publication,
viewportSize: currentLayoutContext().viewportSize,
targetLocation: pendingLocation,
targetHighlightRangeInfo: pendingHighlightRangeInfo,
highlights: highlights(for: page),
searchPresentation: searchPresentation(for: page)
)
}
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 {
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
return activeHighlights.filter { highlight in
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return false
}
return hrefs.contains(normalizedHref)
}
}
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
let href = publication.spine[page.spineIndex].href
let normalizedHref = publication.resourceResolver.normalizedHref(href)
return activeHighlights.filter { highlight in
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
}
}
}
@@ -0,0 +1,241 @@
import UIKit
extension RDEPUBReaderController {
func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|| readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
: nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.pageDirection = resolvedDirection
updateReaderChrome()
if presentationDidChange {
readerView.switchReaderDisplayType(configuration.displayType)
if let preservedLocation {
_ = restoreReadingLocation(preservedLocation)
}
}
}
func startInitialLoadIfNeeded() {
runtime.startInitialLoadIfNeeded()
}
func loadPublication() {
runtime.loadPublication()
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
runtime.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
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?
) {
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
runtime.finishPagination(restoreLocation: restoreLocation)
}
func repaginatePreservingCurrentLocation() {
runtime.repaginatePreservingCurrentLocation()
}
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
runtime.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
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)
}
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
}
func refreshVisibleContentPreservingLocation() {
runtime.refreshVisibleContentPreservingLocation()
}
func rebuildExternalTextBook() {
runtime.rebuildExternalTextBook()
}
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,
brightness: currentBrightness
)
persistence?.saveReaderSettings(settings)
}
func requiresRepagination(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.fontSize != newConfiguration.fontSize ||
oldConfiguration.fontChoice != newConfiguration.fontChoice ||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
oldConfiguration.columnGap != newConfiguration.columnGap ||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode ||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
}
func requiresVisibleRefresh(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.theme != newConfiguration.theme ||
oldConfiguration.darkImageAdjustmentEnabled != newConfiguration.darkImageAdjustmentEnabled ||
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
}
func presentTableOfContents() {
runtime.presentTableOfContents()
}
func handleBackAction() {
runtime.handleBackAction()
}
func handle(error: Error) {
isRepaginating = false
hideLoading()
readerContext.clearActiveSnapshot()
textBook = nil
readerContext.bookPageMap = nil
readerView.reloadData()
errorLabel.text = error.localizedDescription
errorLabel.isHidden = false
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
) {
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
runtime.searchPresentation(for: page)
}
private func resolvedPageDirection() -> RDEpubReaderView.PageDirection {
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
}
}
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}
@@ -0,0 +1,109 @@
import Foundation
extension RDEPUBReaderController {
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContentsItems(
from: publication?.tableOfContents ?? [],
includePageNumbers: false
)
guard !items.isEmpty else { return nil }
guard let publication,
let currentLocation = currentVisibleLocation(),
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
return nil
}
if let textBook,
let chapterData = textBook.chapterData(
for: currentLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
),
let tocItem = chapterData.primaryTableOfContentsItem(
from: publication.tableOfContents,
normalizer: { publication.resourceResolver.normalizedHref($0) }
) {
return items.last { $0.href == tocItem.href } ?? items.last {
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
return items.last { item in
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0,
includePageNumbers: Bool = true
) -> [RDEPUBReaderTableOfContentsItem] {
items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
let current = RDEPUBReaderTableOfContentsItem(
title: item.title,
href: item.href,
depth: depth,
pageNumber: pageNumber
)
return [current] + flattenedTableOfContentsItems(
from: item.children,
depth: depth + 1,
includePageNumbers: includePageNumbers
)
}
}
private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex: Int
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
localPageIndex = summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
} else {
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
}
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
return chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
}
}
@@ -0,0 +1,86 @@
import UIKit
///
///
/// `pageCountOfReaderView`
/// `numberOfPages(in:)` +1
///
/// - readoor epubreadableChapterCount nil
/// - epub + readableChapterCount N bookPageMap
extension RDEPUBReaderController {
/// 访 delegate
var trialWallView: UIView? {
if let cached = cachedTrialWallView {
return cached
}
guard configuration.trialPolicy != nil else { return nil }
let view = delegate?.epubReaderTrialWallView(self)
cachedTrialWallView = view
return view
}
/// 宿
var isTrialWallEnabled: Bool {
configuration.trialPolicy != nil && trialWallView != nil
}
///
/// readableChapterCount bookPageMap
var readableContentPageCount: Int {
let rawCount = pageCountOfReaderView(readerView: readerView)
guard let policy = configuration.trialPolicy,
let readableChapterCount = policy.readableChapterCount,
readableChapterCount > 0,
let pageMap = readerContext.bookPageMap else {
return rawCount
}
// spineIndex >= readableChapterCount =
let firstLockedStart = pageMap.entries
.first { $0.spineIndex >= readableChapterCount }?
.absolutePageStart
return firstLockedStart ?? rawCount
}
/// 0-based nil
var trialWallPageIndex: Int? {
guard isTrialWallEnabled else { return nil }
return readableContentPageCount
}
/// RDEpubReaderView 使 + 1
var effectiveNumberOfPages: Int {
guard isTrialWallEnabled else {
return pageCountOfReaderView(readerView: readerView)
}
return readableContentPageCount + 1
}
/// 宿
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
container.setWallView(trialWallView)
return container
}
}
/// 宿
final class RDEPUBTrialWallContainerView: UIView {
private weak var wallView: UIView?
func setWallView(_ view: UIView?) {
guard wallView !== view else { return }
wallView?.removeFromSuperview()
guard let view else { return }
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: bottomAnchor)
])
wallView = view
}
}
@@ -0,0 +1,491 @@
import UIKit
public final class RDEPUBReaderController: UIViewController {
public weak var delegate: RDEPUBReaderDelegate?
public var configuration: RDEPUBReaderConfiguration {
didSet {
// M-09: Apply all configuration side effects even before view is loaded,
// but UI-dependent actions only after view is loaded.
readerContext.configuration = configuration
guard isViewLoaded else { return }
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
let oldConfiguration = oldValue
applyReaderViewConfiguration()
if isExternalTextBook {
if requiresRepagination(from: oldConfiguration, to: configuration) {
rebuildExternalTextBook()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
return
}
guard publication != nil else { return }
if requiresRepagination(from: oldConfiguration, to: configuration) {
repaginatePreservingCurrentLocation()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
}
}
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
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
}
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()
}
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDEpubReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .darkGray
label.isHidden = true
return label
}()
let paginationHostView = UIView()
/// Chapter-level shared display content/layouter for text pages
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
let textDisplayCache = RDEPUBChapterDisplayContentCache()
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()
/// delegate RDEPUBReaderController+Trial
var cachedTrialWallView: UIView?
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 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)
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
self.epubURL = epubURL
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
self.persistence = persistence
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
readerContext.persistence = persistence
self.currentBrightness = brightness
applyWebViewDebugPolicy()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
title: String,
textFileURL: URL? = nil,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
self.init(
epubURL: URL(string: "about:blank")!,
configuration: resolvedConfig,
persistence: persistence,
dependencies: dependencies
)
self.isExternalTextBook = true
self.textBook = textBook
self.textFileURL = textFileURL
self.currentBookIdentifier = bookIdentifier
self.title = title
self.didStartInitialLoad = true
}
public override func viewDidLoad() {
super.viewDidLoad()
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
runtime.viewportMonitor.viewDidLayoutSubviews()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
runtime.handleMemoryWarning()
textDisplayCache.removeAll()
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
coordinator.animate(alongsideTransition: nil) { _ in
RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))")
}
}
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if readerView.isShowToolView {
installSearchBarView()
}
}
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
searchBarView.alpha = 0
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
} else {
searchBarView.showNoResults()
}
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
searchBarView.updateResults(
sections: searchResultSections(for: searchState),
keyword: searchState.keyword,
currentMatchIndex: searchState.currentMatchIndex
)
}
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
installSearchBarView()
} else if searchState != nil {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}
@@ -0,0 +1,56 @@
import UIKit
public protocol RDEPUBReaderDelegate: AnyObject {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
func epubReaderDidReachEnd(_ reader: UIViewController)
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding)
/// configuration.trialPolicy nil
/// nil nil
/// / 宿 reloadBook() epub
func epubReaderTrialWallView(_ reader: UIViewController) -> UIView?
/// 宿 /
func epubReaderDidReachTrialWall(_ reader: UIViewController)
}
public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
func epubReaderDidReachEnd(_ reader: UIViewController) {}
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {}
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {}
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {}
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {}
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding) {}
func epubReaderTrialWallView(_ reader: UIViewController) -> UIView? { nil }
func epubReaderDidReachTrialWall(_ reader: UIViewController) {}
}
@@ -0,0 +1,351 @@
import UIKit
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] {
switch filterControl.selectedSegmentIndex {
case 1:
return highlights.filter(\.hasNote)
case 2:
return highlights.filter { $0.style == .highlight }
default:
return highlights
}
}
init(
highlights: [RDEPUBHighlight],
theme: RDEPUBReaderTheme,
sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
) {
self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
self.theme = theme
self.sectionTitleProvider = sectionTitleProvider
super.init(style: .plain)
title = "标注"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
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()
applyTheme()
updateEmptyState()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
filteredHighlights.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = "HighlightCell"
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
let highlight = filteredHighlights[indexPath.row]
cell.backgroundColor = theme.contentBackgroundColor
cell.textLabel?.textColor = theme.contentTextColor
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
cell.textLabel?.numberOfLines = 2
cell.textLabel?.text = titleText(for: highlight)
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: highlight)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
}
private func configureFilterControl() {
filterControl.selectedSegmentIndex = 0
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
navigationItem.titleView = filterControl
}
private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor
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() {
guard filteredHighlights.isEmpty else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = emptyStateText()
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
}
@objc private func filterChangedAction() {
tableView.reloadData()
updateEmptyState()
}
private func emptyStateText() -> String {
switch filterControl.selectedSegmentIndex {
case 1:
return "暂无批注"
case 2:
return "暂无划线"
default:
return "暂无标注"
}
}
private func detailText(for highlight: RDEPUBHighlight) -> String {
let style = styleDescription(for: highlight)
let chapter = sectionTitleProvider(highlight)
let note = normalizedNote(highlight.note)
let parts = [style, chapter, note].compactMap { $0 }
if !parts.isEmpty {
return parts.joined(separator: "\n")
}
return highlight.location.href
}
private func titleText(for highlight: RDEPUBHighlight) -> String {
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
if highlight.hasNote {
return "批注: \(text)"
}
return text
}
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
switch highlight.style {
case .highlight:
return highlight.hasNote ? "高亮批注" : "高亮"
case .underline:
return highlight.hasNote ? "划线批注" : "划线"
}
}
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
self?.onSelectHighlight?(highlight)
})
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
self?.presentNoteEditor(for: highlight)
})
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
self?.deleteHighlight(highlight)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController,
let cell = tableView.cellForRow(at: sourceIndexPath) {
popover.sourceView = cell
popover.sourceRect = cell.bounds
}
present(alert, animated: true)
}
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
textField.text = highlight.note
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
guard let self,
let note = alert?.textFields?.first?.text,
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
return
}
var updated = highlight
updated.note = self.normalizedNote(note)
self.highlights[index] = updated
self.tableView.reloadData()
self.onUpdateHighlight?(updated)
self.updateEmptyState()
})
present(alert, animated: true)
}
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
highlights.remove(at: index)
tableView.reloadData()
onDeleteHighlight?(highlight)
updateEmptyState()
}
private func normalizedNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
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"
return formatter
}()
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
self.theme = theme
super.init(style: .plain)
title = "书签"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
applyTheme()
updateEmptyState()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
bookmarks.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = "BookmarkCell"
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
let bookmark = bookmarks[indexPath.row]
cell.backgroundColor = theme.contentBackgroundColor
cell.textLabel?.textColor = theme.contentTextColor
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
cell.textLabel?.numberOfLines = 2
cell.textLabel?.text = titleText(for: bookmark)
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: bookmark)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
}
private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
}
private func updateEmptyState() {
guard bookmarks.isEmpty == false else {
let label = UILabel()
label.text = "暂无书签"
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
tableView.backgroundView = label
return
}
tableView.backgroundView = nil
}
private func titleText(for bookmark: RDEPUBBookmark) -> String {
if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
return chapterTitle
}
return bookmark.location.href
}
private func detailText(for bookmark: RDEPUBBookmark) -> String {
let parts = [
dateFormatter.string(from: bookmark.createdAt),
bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
bookmark.location.href
].compactMap { value -> String? in
guard let value, !value.isEmpty else { return nil }
return value
}
return parts.joined(separator: "\n")
}
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
self?.onSelectBookmark?(bookmark)
})
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
self?.deleteBookmark(bookmark)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController,
let cell = tableView.cellForRow(at: sourceIndexPath) {
popover.sourceView = cell
popover.sourceRect = cell.bounds
}
present(alert, animated: true)
}
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
bookmarks.remove(at: index)
tableView.reloadData()
onDeleteBookmark?(bookmark)
updateEmptyState()
}
}
@@ -0,0 +1,180 @@
import Foundation
public protocol RDEPUBReaderPersistence: AnyObject {
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
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)
}
public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
#endif
return []
}
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
#endif
}
func loadReaderSettings() -> RDEPUBReaderSettings? {
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
#endif
return nil
}
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
#endif
}
}
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let defaults: UserDefaults
private let locationPrefix: String
private let bookmarksPrefix: String
private let highlightsPrefix: String
private let settingsKey: String
public init(
defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.",
bookmarksPrefix: String = "ssreader.epub.bookmarks.",
highlightsPrefix: String = "ssreader.epub.highlights.",
settingsKey: String = "ssreader.epub.settings"
) {
self.defaults = defaults
self.locationPrefix = locationPrefix
self.bookmarksPrefix = bookmarksPrefix
self.highlightsPrefix = highlightsPrefix
self.settingsKey = settingsKey
}
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
return nil
}
do {
return try JSONDecoder().decode(RDEPUBLocation.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)")
#endif
return nil
}
}
public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
do {
let data = try JSONEncoder().encode(location)
defaults.set(data, forKey: locationPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)")
#endif
}
}
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
return []
}
do {
return try JSONDecoder().decode([RDEPUBBookmark].self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)")
#endif
return []
}
}
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
do {
let data = try JSONEncoder().encode(bookmarks)
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
#endif
}
}
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
return []
}
do {
return try JSONDecoder().decode([RDEPUBHighlight].self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)")
#endif
return []
}
}
public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
do {
let data = try JSONEncoder().encode(highlights)
if data.count > 1_048_576 {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
#endif
}
}
public func loadReaderSettings() -> RDEPUBReaderSettings? {
guard let data = defaults.data(forKey: settingsKey) else {
return nil
}
do {
return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)")
#endif
return nil
}
}
public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
do {
let data = try JSONEncoder().encode(settings)
defaults.set(data, forKey: settingsKey)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)")
#endif
}
}
}
@@ -0,0 +1,634 @@
import UIKit
struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
let title: String
let items: [Item]
}
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 = {
let field = UITextField()
field.placeholder = "搜索"
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
field.returnKeyType = .search
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.clearButtonMode = .whileEditing
field.isAccessibilityElement = true
if #available(iOS 13.0, *) {
field.accessibilityTraits = .searchField
}
return field
}()
private let backgroundButton = UIButton(type: .custom)
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()
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) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.search.bar"
shouldGroupAccessibilityChildren = false
isAccessibilityElement = false
setupSubviews()
setupConstraints()
setupActions()
updateLegacyNavigationEnabled(false)
showInitialState()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the
// accessibility-only navigation buttons. Taps on the dimmed scrim
// area pass through to the reader content below, allowing chrome
// toggle taps to work.
let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, with: event) {
return super.hitTest(point, with: event)
}
// Also claim touches on the accessibility-only navigation buttons
if previousButton.frame.contains(point)
|| nextButton.frame.contains(point)
|| countLabel.frame.contains(point) {
return super.hitTest(point, with: event)
}
return nil
}
override func lineFrame(in bounds: CGRect) -> CGRect {
.zero
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
let overlayColor = isDarkBackground
? UIColor(white: 0.12, alpha: 0.92)
: UIColor(white: 0.08, alpha: 0.82)
let panelColor = isDarkBackground
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
let rowColor = isDarkBackground
? UIColor(white: 0.18, alpha: 1)
: UIColor(white: 0.14, alpha: 0.96)
let cardColor = isDarkBackground
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.10, alpha: 0.98)
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
let textColor = UIColor(white: 0.96, alpha: 1)
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor
panelView.backgroundColor = panelColor
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal)
textField.textColor = textColor
textField.tintColor = UIColor.systemBlue
textField.keyboardAppearance = isDarkBackground ? .dark : .default
textField.attributedPlaceholder = NSAttributedString(
string: "搜索",
attributes: [.foregroundColor: secondaryTextColor]
)
emptyStateLabel.textColor = secondaryTextColor
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
previousButton.tintColor = textColor
nextButton.tintColor = textColor
countLabel.textColor = textColor
countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
tableView.reloadData()
}
var presentedView: UIView {
panelView
}
func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)"
textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0)
}
func showNoResults() {
currentMatchIndex = nil
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
}
func showSearching() {
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "搜索中..."
}
func restoreKeyword(_ keyword: String) {
textField.text = keyword
self.keyword = keyword
}
func updateResults(
sections: [RDEPUBReaderSearchSection],
keyword: String,
currentMatchIndex: Int?
) {
self.keyword = keyword
self.searchSections = sections
self.currentMatchIndex = currentMatchIndex
let total = sections.reduce(0) { $0 + $1.items.count }
if let currentMatchIndex, total > 0 {
updateMatchCount(current: currentMatchIndex + 1, total: total)
} else {
updateMatchCount(current: 0, total: total)
}
if total == 0 {
showNoResults()
return
}
emptyStateLabel.isHidden = true
tableView.isHidden = false
tableView.reloadData()
scrollToCurrentMatchIfNeeded()
}
private func setupSubviews() {
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
searchIcon.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
cancelButton.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
previousButton.translatesAutoresizingMaskIntoConstraints = false
nextButton.translatesAutoresizingMaskIntoConstraints = false
countLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundButton)
addSubview(panelView)
panelView.addSubview(grabberView)
panelView.addSubview(searchRowView)
panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel)
searchRowView.addSubview(searchFieldContainer)
searchRowView.addSubview(searchFieldDivider)
searchRowView.addSubview(cancelButton)
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
if #available(iOS 13.0, *) {
searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
}
searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22
searchRowView.clipsToBounds = true
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
// 0.02 0.01CALayer.opacity Float320.01
// 0.01 UIKit
previousButton.alpha = 0.02
nextButton.alpha = 0.02
countLabel.alpha = 0.02
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self
tableView.delegate = self
tableView.showsVerticalScrollIndicator = false
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
grabberView.widthAnchor.constraint(equalToConstant: 92),
grabberView.heightAnchor.constraint(equalToConstant: 6),
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
searchRowView.heightAnchor.constraint(equalToConstant: 52),
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 24),
searchIcon.heightAnchor.constraint(equalToConstant: 24),
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
// /UI panel
//
//
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
])
}
private func setupActions() {
textField.delegate = self
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
nextButton.isEnabled = enabled
}
private func showInitialState() {
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "输入关键词开始搜索"
}
private func scrollToCurrentMatchIfNeeded() {
guard let currentMatchIndex else { return }
for (sectionIndex, section) in searchSections.enumerated() {
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
DispatchQueue.main.async { [weak self] in
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
return
}
}
}
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
searchSections[indexPath.section].items[indexPath.row]
}
@objc private func textFieldDidReturn() {
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !keyword.isEmpty else { return }
onSearchSubmit?(keyword)
textField.resignFirstResponder()
}
@objc private func textFieldDidChange() {
guard textField.markedTextRange == nil else { return }
onSearchTextChanged?(textField.text ?? "")
}
@objc private func previousAction() {
onSearchPrevious?()
}
@objc private func nextAction() {
onSearchNext?()
}
@objc private func closeAction() {
onClose?()
}
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchSections[section].items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
for: indexPath
)
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
return cell
}
let item = item(at: indexPath)
cell.configure(
previewText: item.previewText,
keyword: keyword,
isCurrent: item.isCurrent
)
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let container = UIView()
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1)
label.text = searchSections[section].title
label.numberOfLines = 2
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
])
return container
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
40
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
116
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
onSelectMatch?(item(at: indexPath).matchIndex)
}
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
}
return true
}
}
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?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupSubviews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewLabel.attributedText = nil
}
func configure(previewText: String, keyword: String, isCurrent: Bool) {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
previewLabel.attributedText = attributedPreviewText(
previewText,
keyword: keyword,
isCurrent: isCurrent
)
}
private func setupSubviews() {
cardView.translatesAutoresizingMaskIntoConstraints = false
previewLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(cardView)
cardView.addSubview(previewLabel)
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
previewLabel.numberOfLines = 0
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
])
}
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
let normalizedText = previewText
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 8
let attributed = NSMutableAttributedString(
string: normalizedText,
attributes: [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: Self.primaryTextColor,
.paragraphStyle: paragraphStyle
]
)
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchKeyword.isEmpty else { return attributed }
let nsText = normalizedText as NSString
var searchRange = NSRange(location: 0, length: nsText.length)
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
while searchRange.length > 0 {
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
let nextLocation = foundRange.location + max(foundRange.length, 1)
guard nextLocation < nsText.length else { break }
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
}
return attributed
}
}
@@ -0,0 +1,19 @@
import Foundation
public struct RDEPUBReaderTableOfContentsItem: Equatable {
public var title: String
public var href: String
public var depth: Int
public var pageNumber: Int?
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
self.title = title
self.href = href
self.depth = depth
self.pageNumber = pageNumber
}
}
@@ -0,0 +1,35 @@
import UIKit
open class RDEPUBReaderToolView: UIView {
private let lineView = UIView()
private let lineHeight: CGFloat = 0.5
override init(frame: CGRect) {
super.init(frame: frame)
translatesAutoresizingMaskIntoConstraints = false
addSubview(lineView)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open override func layoutSubviews() {
super.layoutSubviews()
lineView.frame = lineFrame(in: bounds)
}
open func apply(theme: RDEPUBReaderTheme) {
backgroundColor = .white
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
}
open func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
}
}
final class RDEPUBReaderTintButton: UIButton {
}
@@ -0,0 +1,69 @@
import UIKit
/// 宿
/// `RDEPUBReaderTopToolView`
/// `RDEPUBReaderDependencies.makeTopToolView`
public protocol RDEPUBReaderTopToolViewProtocol: AnyObject {
/// /
var onBack: (() -> Void)? { get set }
///
var onSearch: (() -> Void)? { get set }
///
var onToggleBookmark: (() -> Void)? { get set }
///
func apply(theme: RDEPUBReaderTheme)
///
func setTitle(_ title: String?)
///
func setBookmarkEnabled(_ isEnabled: Bool)
///
func setBookmarkSelected(_ isSelected: Bool)
}
/// 宿 `RDEPUBReaderBottomToolView`
/// `RDEPUBReaderDependencies.makeBottomToolView`
public protocol RDEPUBReaderBottomToolViewProtocol: AnyObject {
///
var onShowTableOfContents: (() -> Void)? { get set }
///
var onShowBookmarks: (() -> Void)? { get set }
///
var onShowHighlights: (() -> Void)? { get set }
/// /
var onAddHighlight: (() -> Void)? { get set }
///
var onShowSettings: (() -> Void)? { get set }
///
func apply(theme: RDEPUBReaderTheme)
///
func updateVisibility(showsTableOfContents: Bool, allowsHighlights: Bool, showsSettingsPanel: Bool)
///
func setBookmarksEnabled(_ isEnabled: Bool)
///
func setAddHighlightEnabled(_ isEnabled: Bool)
///
func setHighlightsEnabled(_ isEnabled: Bool)
}
/// UIView
public typealias RDEPUBReaderTopToolViewProviding = UIView & RDEPUBReaderTopToolViewProtocol
/// UIView
public typealias RDEPUBReaderBottomToolViewProviding = UIView & RDEPUBReaderBottomToolViewProtocol
@@ -0,0 +1,136 @@
import UIKit
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView, RDEPUBReaderTopToolViewProtocol {
public var onBack: (() -> Void)?
public var onToggleBookmark: (() -> Void)?
public var onSearch: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)
private let searchButton = RDEPUBReaderTintButton(type: .system)
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
private let titleLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
label.numberOfLines = 1
return label
}()
private var isBookmarked = false
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.topToolbar"
self.backgroundColor = .white
addSubview(backButton)
addSubview(searchButton)
addSubview(bookmarkButton)
addSubview(titleLabel)
backButton.translatesAutoresizingMaskIntoConstraints = false
searchButton.translatesAutoresizingMaskIntoConstraints = false
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
NSLayoutConstraint.activate([
backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
backButton.widthAnchor.constraint(equalToConstant: 44),
backButton.heightAnchor.constraint(equalToConstant: 44),
bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
searchButton.widthAnchor.constraint(equalToConstant: 44),
searchButton.heightAnchor.constraint(equalToConstant: 44),
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
])
if #available(iOS 13.0, *) {
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
backButton.setTitle("返回", for: .normal)
searchButton.setTitle("搜索", for: .normal)
}
backButton.accessibilityIdentifier = "epub.reader.back"
searchButton.accessibilityIdentifier = "epub.reader.search"
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
titleLabel.accessibilityIdentifier = "epub.reader.title"
updateBookmarkButtonAppearance()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
}
override public func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
titleLabel.textColor = theme.toolControlTextColor
backButton.tintColor = theme.toolControlTextColor
searchButton.tintColor = theme.toolControlTextColor
bookmarkButton.tintColor = theme.toolControlTextColor
if #unavailable(iOS 13.0) {
backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
}
updateBookmarkButtonAppearance()
}
public func setTitle(_ title: String?) {
titleLabel.text = title
}
public func setBookmarkSelected(_ isSelected: Bool) {
isBookmarked = isSelected
updateBookmarkButtonAppearance()
}
public func setBookmarkEnabled(_ isEnabled: Bool) {
bookmarkButton.isEnabled = isEnabled
bookmarkButton.alpha = isEnabled ? 1 : 0.45
}
@objc private func backAction() {
onBack?()
}
@objc private func searchAction() {
onSearch?()
}
@objc private func bookmarkAction() {
onToggleBookmark?()
}
private func updateBookmarkButtonAppearance() {
if #available(iOS 13.0, *) {
let imageName = isBookmarked ? "bookmark.fill" : "bookmark"
bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
}
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
}
}
@@ -0,0 +1,84 @@
import UIKit
/// Debug-only automation (`--demo-settings-flip`) that replays the user
/// gesture sequence suspected of producing stale page tables: open the
/// settings panel, change the line height, close the panel, flip pages
/// with several timing variants aimed at the preview-repagination and
/// in-flight chapter build races. Combine with
/// `--demo-pagination-validate` to detect any resulting metric mismatch.
enum RDEPUBSettingsFlipAutomation {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-settings-flip")
private(set) static var hasStarted = false
static func startIfNeeded(controller: RDEPUBReaderController) {
guard isEnabled, !hasStarted else { return }
hasStarted = true
print("[SETTINGS-FLIP] scheduled")
// Pass 1 starts while progressive pagination of the freshly opened
// book is still running, so prefetch builds are in flight.
run(after: 2.0) { [weak controller] in
guard let controller else { return }
pass(controller: controller, index: 1, lineHeight: 1.8, changeToCloseDelay: 1.2) {
pass(controller: controller, index: 2, lineHeight: 1.6, changeToCloseDelay: 0.05) {
pass(controller: controller, index: 3, lineHeight: 1.8, changeToCloseDelay: 0.3) {
print("[SETTINGS-FLIP] finished all passes")
}
}
}
}
}
/// One panel round-trip: present change line height close after
/// `changeToCloseDelay` flip pages forward and back.
private static func pass(
controller: RDEPUBReaderController,
index: Int,
lineHeight: CGFloat,
changeToCloseDelay: TimeInterval,
completion: @escaping () -> Void
) {
print("[SETTINGS-FLIP] pass \(index) present panel")
controller.presentSettings()
run(after: 0.7) { [weak controller] in
guard let controller else { return }
print("[SETTINGS-FLIP] pass \(index) set lineHeightMultiple=\(lineHeight)")
controller.updateConfiguration { $0.lineHeightMultiple = lineHeight }
run(after: changeToCloseDelay) { [weak controller] in
guard let controller else { return }
print("[SETTINGS-FLIP] pass \(index) close panel")
controller.dismiss(animated: true) { [weak controller] in
controller?.runtime.settingsPanelDidDisappear()
run(after: 1.0) { [weak controller] in
guard let controller else { return }
flipPages(controller: controller, index: index, completion: completion)
}
}
}
}
}
private static func flipPages(
controller: RDEPUBReaderController,
index: Int,
completion: @escaping () -> Void
) {
let startPage = controller.readerView.currentPage + 1
let offsets = [1, 2, 3, 2, 1, 0]
print("[SETTINGS-FLIP] pass \(index) flip pages from \(startPage)")
for (step, offset) in offsets.enumerated() {
run(after: 0.6 * Double(step + 1)) { [weak controller] in
_ = controller?.go(toPageNumber: startPage + offset, animated: false)
}
}
run(after: 0.6 * Double(offsets.count + 1) + 0.5, block: completion)
}
private static func run(after delay: TimeInterval, block: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)
}
}
@@ -0,0 +1,34 @@
import UIKit
struct RDEPUBViewportSignature: Equatable {
let width: CGFloat
let height: CGFloat
let safeTop: CGFloat
let safeLeft: CGFloat
let safeBottom: CGFloat
let safeRight: CGFloat
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
abs(width - other.width) > threshold ||
abs(height - other.height) > threshold ||
abs(safeTop - other.safeTop) > threshold ||
abs(safeLeft - other.safeLeft) > threshold ||
abs(safeBottom - other.safeBottom) > threshold ||
abs(safeRight - other.safeRight) > threshold
}
}
enum RDEPUBViewportChangeReason {
case viewLayout
case orientationTransition
}
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
@@ -0,0 +1,135 @@
import UIKit
protocol RDEPUBWebContentViewDelegate: AnyObject {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
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)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
}
final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing {
weak var delegate: RDEPUBWebContentViewDelegate?
/// The current href of the loaded chapter (exposed for image resolution).
var currentHref: String {
epubWebView.currentHref
}
private let epubWebView = RDEPUBWebView()
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
layer.masksToBounds = true
addSubview(epubWebView)
addSubview(decorationOverlayView)
addSubview(pageNumberLabel)
epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
epubWebView.frame = bounds
decorationOverlayView.frame = bounds
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
width: labelSize.width,
height: labelSize.height
)
}
func configure(
publication: RDEPUBPublication,
request: RDEPUBRenderRequest,
pageNumber: Int,
totalPages: Int,
theme: RDEPUBReaderTheme
) {
backgroundColor = theme.contentBackgroundColor
pageNumberLabel.textColor = theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
decorationOverlayView.applyDecorations([])
epubWebView.load(publication: publication, request: request)
setNeedsLayout()
}
func releaseResources() {
decorationOverlayView.applyDecorations([])
epubWebView.reset()
epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
}
}
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebContentView(self, didRequestSelectionAction: action)
}
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
delegate?.epubWebContentView(self, didActivateExternalLink: url)
}
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
}
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {
delegate?.epubWebContentView(self, didTapImageWithSource: src, sourceRect: sourceRect)
}
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
delegate?.epubWebContentView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect)
}
}
extension RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
// Default: no-op. Implementors can present an image viewer.
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
// Default: no-op. Implementors can present a footnote tooltip.
}
}
@@ -0,0 +1,48 @@
import UIKit
final class RDEPUBWebDecorationOverlayView: UIView {
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let verticalAdjustment: CGFloat = -1
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations.filter { !$0.rects.isEmpty }
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in decorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
}
@@ -0,0 +1,516 @@
import UIKit
public final class RDEpubURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
let animated: Bool
var attemptCount: Int
}
private let bookURL: URL
private let epubConfiguration: RDEPUBReaderConfiguration
private var embeddedController: UIViewController?
private let demoStateLabel: UILabel = {
let label = UILabel()
label.accessibilityIdentifier = "demo.reader.state"
label.font = .systemFont(ofSize: 1)
label.textColor = .clear
label.alpha = 0.01
label.isAccessibilityElement = true
label.text = "reader=opening"
return label
}()
private var pendingDemoPageRequest: PendingDemoPageRequest?
private var isRetryingPendingDemoPage = false
private let maxPendingDemoPageAttempts = 24
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
private var demoStateTimer: Timer?
private var lastEmittedDemoState = ""
private var pendingSearchKeyword: String?
private var externalLinkActivationCount = 0
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
public init(
bookURL: URL,
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
) {
self.bookURL = bookURL
self.epubConfiguration = epubConfiguration
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = bookURL.deletingPathExtension().lastPathComponent
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
embedReaderController()
installDemoStateLabel()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startDemoStateTimerIfNeeded()
refreshDemoState()
}
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopDemoStateTimer()
}
deinit {
stopDemoStateTimer()
}
public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
readerController?.configuration.displayType = displayType
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
}
@discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = performDemoPageNavigation(pageNumber, animated: animated)
if moved {
pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
} else {
queuePendingDemoPageNavigation(pageNumber, animated: animated)
}
return moved
}
public func runDemoDisplaySequence(
_ displayTypes: [RDEpubReaderView.DisplayType],
initialPageNumber: Int? = nil,
stepDelay: TimeInterval = 1.0
) {
let normalizedDelay = max(stepDelay, 0.1)
if let initialPageNumber {
DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in
_ = self?.goToDemoPage(initialPageNumber)
}
}
for (index, displayType) in displayTypes.enumerated() {
let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.applyDemoDisplayType(displayType)
}
}
}
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
if readerController.parser != nil || readerController.textBook != nil {
submitSearchAfterDelay(keyword: keyword, retries: 10)
} else {
pendingSearchKeyword = keyword
}
}
private func submitSearchAfterDelay(keyword: String, retries: Int = 0) {
guard let readerController else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
readerController.search(keyword: keyword)
readerController.updateSearchCount()
// bookPageMap
// token
if retries > 0,
readerController.searchState?.matches.isEmpty != false {
self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
}
}
}
private func executePendingSearchIfNeeded() {
guard let keyword = pendingSearchKeyword else { return }
guard let readerController, readerController.parser != nil || readerController.textBook != nil else {
return
}
pendingSearchKeyword = nil
submitSearchAfterDelay(keyword: keyword, retries: 10)
}
private func embedReaderController() {
let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" {
controller = RDEPUBReaderController(
epubURL: bookURL,
configuration: epubConfiguration
)
} else {
let bookIdentifier = bookURL.lastPathComponent
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
let edgeInsets = UIEdgeInsets(
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom),
right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right)
)
let builder = RDEpubPlainTextBookBuilder(
layoutConfig: RDEPUBTextLayoutConfig(
frameWidth: pageSize.width,
frameHeight: pageSize.height,
edgeInsets: edgeInsets,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
)
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
controller = RDEPUBReaderController(
textBook: textBook,
bookIdentifier: bookIdentifier,
title: bookTitle,
textFileURL: bookURL,
configuration: epubConfiguration
)
} else {
let fallback = UIViewController()
let textView = UITextView()
textView.isEditable = false
textView.text = rd_decodeTextFile(url: bookURL)
fallback.view = textView
controller = fallback
}
}
embeddedController = controller
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
controller.didMove(toParent: self)
readerController?.delegate = self
emitDemoState()
}
private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
@discardableResult
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
guard let readerController else { return false }
guard pageNumber > 0 else { return false }
let knownPages = readerController.readerContext.bookPageMap?.totalPages
?? readerController.textBook?.pages.count
?? readerController.activePages.count
let numPages = readerController.readerView.numberOfPages()
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
if moved {
emitDemoState(prefix: "page=\(pageNumber)")
}
return moved
}
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
if let pendingDemoPageRequest,
pendingDemoPageRequest.pageNumber == pageNumber,
pendingDemoPageRequest.animated == animated {
return
}
pendingDemoPageRequest = PendingDemoPageRequest(
pageNumber: pageNumber,
animated: animated,
attemptCount: 0
)
retryPendingDemoPageIfNeeded()
}
private func retryPendingDemoPageIfNeeded() {
guard !isRetryingPendingDemoPage else { return }
guard pendingDemoPageRequest != nil else { return }
isRetryingPendingDemoPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
self?.attemptPendingDemoPageNavigation()
}
}
private func attemptPendingDemoPageNavigation() {
guard var pendingDemoPageRequest else {
isRetryingPendingDemoPage = false
return
}
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
self.pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
return
}
pendingDemoPageRequest.attemptCount += 1
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
self.pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
return
}
self.pendingDemoPageRequest = pendingDemoPageRequest
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
self?.attemptPendingDemoPageNavigation()
}
}
private func logDemoState(prefix: String) {
guard let readerController else { return }
let location = readerController.currentLocation
let href = location?.href ?? "nil"
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let page = readerController.currentPageNumber.map(String.init) ?? "nil"
}
private func installDemoStateLabel() {
view.addSubview(demoStateLabel)
demoStateLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor),
demoStateLabel.widthAnchor.constraint(equalToConstant: 1),
demoStateLabel.heightAnchor.constraint(equalToConstant: 1)
])
}
private func startDemoStateTimerIfNeeded() {
guard demoStateTimer == nil else { return }
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshDemoState(logIfChanged: false)
}
if let demoStateTimer {
RunLoop.main.add(demoStateTimer, forMode: .common)
}
}
private func stopDemoStateTimer() {
demoStateTimer?.invalidate()
demoStateTimer = nil
}
private func emitDemoState(prefix: String? = nil) {
refreshDemoState(logPrefix: prefix, logIfChanged: true)
}
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
let highlights = readerController?.highlights.count ?? 0
let bookmarks = readerController?.activeBookmarks.count ?? 0
let selection = readerController?.currentSelection == nil ? 0 : 1
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()
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
?? (fileCount: 0, totalBytes: 0)
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
let state = [
"reader=opened",
"page=\(page)",
"display=\(display)",
"toolbar=\(toolbar)",
"highlights=\(highlights)",
"bookmarks=\(bookmarks)",
"selection=\(selection)",
"href=\(href)",
"progression=\(progression)",
"cfi=\(cfi)",
"lastCFI=\(lastCFI)",
"rangeCFI=\(rangeCFI)",
"mode=\(mapSnapshot.mode)",
"pagination=\(mapSnapshot.phase)",
"knownPages=\(mapSnapshot.knownPages)",
"knownChapters=\(mapSnapshot.knownChapters)",
"buildableChapters=\(mapSnapshot.buildableChapters)",
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
"inspectable=\(inspectable ? 1 : 0)",
"streamedResources=\(resourceMetrics.streamedResponses)",
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
"resourceFailures=\(resourceMetrics.failures)",
"cacheFiles=\(cacheStats.fileCount)",
"cacheBytes=\(cacheStats.totalBytes)",
"externalLinks=\(externalLinkActivationCount)",
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))",
"footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))"
].joined(separator: " ")
demoStateLabel.text = state
if let logPrefix {
logDemoState(prefix: logPrefix)
} else if logIfChanged, state != lastEmittedDemoState {
}
lastEmittedDemoState = state
}
private func encodedDemoLocationHref(_ href: String?) -> String {
guard let href, !href.isEmpty else { return "nil" }
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
}
private func encodedDemoField(_ value: String?) -> String {
guard let value, !value.isEmpty else { return "nil" }
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
}
private func currentSearchMatchText() -> String {
guard let match = readerController?.searchState?.currentMatch else {
return "none"
}
// Primary path: extract the exact matched text from the chapter content
if let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) {
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
if nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length {
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
}
}
// Fallback: chapter data may not be cached yet (on-demand loading).
// If the match length equals the keyword length, the keyword itself
// is the match text. Otherwise extract from previewText at the known offset.
if let keyword = readerController?.searchState?.keyword,
match.rangeLength == keyword.count {
return keyword
}
// Final fallback: return "none" to indicate data not yet available
return "none"
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
guard let readerController else {
return ("unavailable", "none", 0, 0, 0)
}
if let bookPageMap = readerController.readerContext.bookPageMap {
let buildableChapters = readerController.publication?.spine.filter {
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
}.count ?? 0
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
}
if let textBook = readerController.textBook {
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
}
let snapshotChapters = readerController.activeChapters.count
let snapshotPages = readerController.activePages.count
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
}
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)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: epubConfiguration.theme.contentTextColor,
backgroundColor: epubConfiguration.theme.contentBackgroundColor
)
}
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
}
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
return content
}
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
return content
}
return ""
}
}
extension RDEpubURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
retryPendingDemoPageIfNeeded()
executePendingSearchIfNeeded()
emitDemoState()
}
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
retryPendingDemoPageIfNeeded()
executePendingSearchIfNeeded()
emitDemoState()
}
public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {
emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)")
}
public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {
emitDemoState(prefix: "highlights=\(highlights.count)")
}
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
}
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
externalLinkActivationCount += 1
lastActivatedExternalURL = url
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
}
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
lastReaderErrorDescription = String(describing: error)
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
}
}
private extension RDEpubReaderView.DisplayType {
var demoArgumentValue: String {
switch self {
case .pageCurl:
return "pageCurl"
case .horizontalScroll:
return "horizontalScroll"
case .verticalScroll:
return "verticalScroll"
}
}
}
@@ -0,0 +1,10 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
}
}
@@ -0,0 +1,112 @@
import Foundation
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
let pageCount: Int
let absolutePageStart: Int
let fragmentOffsets: [String: Int]
}
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
private let indexBySpine: [Int: Int]
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
self.entries = entries
var mapping: [Int: Int] = [:]
for (i, entry) in entries.enumerated() {
mapping[entry.spineIndex] = i
}
self.indexBySpine = mapping
self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
}
static let empty = RDEPUBBookPageMap(entries: [])
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
return entry.absolutePageStart + localPageIndex
}
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if entries[mid].absolutePageStart <= absolutePage {
lo = mid + 1
} else {
hi = mid
}
}
guard lo > 0 else { return nil }
return entries[lo - 1].spineIndex
}
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
let entry = entries[idx]
let local = absolutePage - entry.absolutePageStart
guard local >= 0, local < entry.pageCount else { return nil }
return local
}
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
var totalChapters: Int { entries.count }
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]) {
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
}
func build() -> RDEPUBBookPageMap {
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
for item in sorted {
entries.append(RDEPUBBookPageMapEntry(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: item.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: item.fragmentOffsets
))
absolutePageStart += item.pageCount
}
return RDEPUBBookPageMap(entries: entries)
}
}
}
@@ -0,0 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -0,0 +1,75 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
private let maxEntryCount: Int
private let lock = NSLock()
init(maxEntryCount: Int = 30) {
self.maxEntryCount = maxEntryCount
}
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
get {
lock.lock()
defer { lock.unlock() }
guard let chapter = storage[spineIndex] else {
return nil
}
touchLocked(spineIndex)
return chapter
}
set {
lock.lock()
defer { lock.unlock() }
if let newValue {
storage[spineIndex] = newValue
touchLocked(spineIndex)
// Evict oldest entries if over limit
evictIfNeededLocked()
} else {
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
}
}
var storedSpineIndices: [Int] {
lock.lock()
defer { lock.unlock() }
return Array(storage.keys)
}
func remove(spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
accessOrder.removeAll()
}
/// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
/// Must be called while holding lock.
private func evictIfNeededLocked() {
while storage.count > maxEntryCount, let oldest = accessOrder.first {
storage.removeValue(forKey: oldest)
accessOrder.removeFirst()
}
}
/// Marks an entry as most recently used.
/// Must be called while holding lock.
private func touchLocked(_ spineIndex: Int) {
accessOrder.removeAll { $0 == spineIndex }
accessOrder.append(spineIndex)
}
}
@@ -0,0 +1,838 @@
import Foundation
final class RDEPUBChapterLoader {
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
private struct PendingLoad {
var priority: LoadPriority
var completions: [LoadCompletion]
}
private weak var context: RDEPUBReaderContext?
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let pendingLoadsLock = NSLock()
private var pendingLoads: [Int: PendingLoad] = [:]
var onDeferredCFIMapReady: ((Int) -> Void)?
init(context: RDEPUBReaderContext) {
self.context = context
}
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
summaryDiskCache = cache
}
enum LoadPriority {
case navigation
case preview
case prefetch
}
private enum LoadRegistrationResult {
case created
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
let layoutSnapshot = context?.makeLayoutSnapshot()
loadChapterWithSnapshot(
spineIndex: spineIndex,
store: store,
priority: priority,
layoutSnapshot: layoutSnapshot,
completion: completion
)
}
private func loadChapterWithSnapshot(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
layoutSnapshot: RDEPUBLayoutSnapshot?,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
)
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
DispatchQueue.main.async {
completion(.success(cached))
}
return
}
let completionOnMain: LoadCompletion = { result in
DispatchQueue.main.async {
completion(result)
}
}
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: priority,
completion: completionOnMain
)
switch registration {
case .joined(let existingPriority, let effectivePriority):
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
)
return
case .created:
break
}
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [weak self] in
guard let self, let context = self.context else {
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
let pageRangeSource: String
if precomputedPageRanges != nil {
pageRangeSource = "HIT(memoryPageCount)"
} else if diskPageRanges != nil {
pageRangeSource = "HIT(diskSummary)"
} else {
pageRangeSource = "MISS(fullRender)"
}
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
)
let buildStart = CFAbsoluteTimeGetCurrent()
do {
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary,
context: context,
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
)
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pc, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
store.endPendingChapterLoad(for: spineIndex)
switch effectivePriority {
case .navigation:
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
return
}
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .preview:
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .prefetch:
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
}
} catch {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
)
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
}
}
}
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
}
if let cached = store?.chapterData(for: spineIndex) {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
return cached
}
guard let store else {
throw RDEPUBChapterLoadError.missingParser
}
if store.hasPendingChapterLoad(for: spineIndex) {
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: .navigation
) { pendingResult in
result = pendingResult
semaphore.signal()
}
if case .joined(let existingPriority, let effectivePriority) = registration {
semaphore.wait()
return try result!.get()
}
}
store.assertNotOnChapterLoadQueue()
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
store.chapterLoadQueue.async {
do {
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary,
context: context,
layoutSnapshot: snapshot
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pageCount, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
return chapter
}
result = .success(chapter)
} catch {
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
return try result!.get()
}
private func registerPendingLoad(
spineIndex: Int,
priority: LoadPriority,
completion: @escaping LoadCompletion
) -> LoadRegistrationResult {
pendingLoadsLock.lock()
defer { pendingLoadsLock.unlock() }
if var pending = pendingLoads[spineIndex] {
let existingPriority = pending.priority
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
pending.completions.append(completion)
pendingLoads[spineIndex] = pending
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
}
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
return .created
}
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
pendingLoadsLock.lock()
let priority = pendingLoads[spineIndex]?.priority
pendingLoadsLock.unlock()
return priority
}
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
pendingLoadsLock.lock()
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
pendingLoadsLock.unlock()
guard !completions.isEmpty else { return }
completions.forEach { $0(result) }
}
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
if let snapshot = layoutSnapshot {
pageSize = snapshot.pageSize
style = snapshot.style
layoutConfig = snapshot.layoutConfig
} else {
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
}
if let pageRanges = availablePageRanges {
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
pageRanges: pageRanges,
parser: parser,
publication: publication,
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
diskSummary: diskSummary,
context: context
)
}
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
}
return try assembleRuntimeChapter(
from: result.chapter,
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig,
context: context,
layoutSnapshot: layoutSnapshot
)
}
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let renderer = context.resolvedTextRenderer()
let rendered = try renderer.renderChapter(request: request)
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: typesetString, style: style, layoutConfig: layoutConfig
)
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 {
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
let pages = buildPagesFromRanges(
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
title: title,
metadataSource: metadataSource
)
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap,
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
spineIndex: Int,
href: String,
title: String,
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
) -> [RDEPUBTextPage] {
let totalPageCount = pageRanges.count
return pageRanges.enumerated().map { (pageIndex, range) in
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
metadata = metaList[pageIndex].toPageMetadata()
} else {
metadata = inferPageMetadata(
from: typesetString,
range: range,
isLastPage: pageIndex == totalPageCount - 1
)
}
return RDEPUBTextPage(
absolutePageIndex: -1,
chapterIndex: 0,
spineIndex: spineIndex,
href: href,
chapterTitle: title,
pageIndexInChapter: pageIndex,
totalPagesInChapter: totalPageCount,
chapterContent: typesetString,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + range.length - 1,
metadata: 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,
range: NSRange,
isLastPage: Bool
) -> RDEPUBTextPageMetadata {
var attachmentRanges: [NSRange] = []
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
var blockKinds: [RDEPUBTextBlockKind] = []
var semanticHints: [RDEPUBTextSemanticHint] = []
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
var trailingFragmentID: String? = nil
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
attachmentRanges.append(attrRange)
attachmentKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!blockKinds.contains(kind) {
blockKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
if let rawValue = value as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
for hint in hints where !semanticHints.contains(hint) {
semanticHints.append(hint)
}
}
}
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!attachmentPlacements.contains(placement) {
attachmentPlacements.append(placement)
}
}
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
if let fid = value as? String {
trailingFragmentID = fid
stop.pointee = true
}
}
return RDEPUBTextPageMetadata(
breakReason: isLastPage ? .chapterEnd : .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
blockKinds: blockKinds,
semanticHints: semanticHints,
attachmentPlacements: attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap,
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
sourceAttributedString: nil,
typesetAttributedString: chapter.attributedContent,
layouter: layouter,
pageRanges: pageRanges,
pages: chapter.pages,
chapterOffsetMap: offsetMap
)
}
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let lineHeightMultiple: CGFloat
if let snapshot = layoutSnapshot {
style = snapshot.style
layoutConfig = snapshot.layoutConfig
lineHeightMultiple = context.configuration.lineHeightMultiple
} else {
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
let pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
lineHeightMultiple = context.configuration.lineHeightMultiple
}
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
return RDEPUBChapterCacheKey(
bookID: context.currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
)
}
private func scheduleDeferredCFIMapBuildIfNeeded(
for chapter: RDEPUBRuntimeChapter,
cacheKey: RDEPUBChapterCacheKey,
store: RDEPUBChapterRuntimeStore
) {
guard chapter.chapterOffsetMap.cfiMap == nil,
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
return
}
let spineIndex = chapter.spineIndex
let href = chapter.href
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
let chapterText = chapter.chapterOffsetMap.chapterText
store.chapterLoadQueue.async {
defer { store.endBuildingCFIMap(for: spineIndex) }
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
let chapterText else {
return
}
let cfiMap = self.makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: fragmentOffsets,
rawHTML: rawHTML,
chapterText: chapterText
)
chapter.updateCFIMap(cfiMap)
self.summaryDiskCache?.write(
summary: self.makeSummary(
for: chapter.pages,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
offsetMap: chapter.chapterOffsetMap,
cacheKey: cacheKey
),
for: cacheKey
)
DispatchQueue.main.async {
self.onDeferredCFIMapReady?(spineIndex)
}
}
}
private func makeSummary(
for pages: [RDEPUBTextPage],
fragmentOffsets: [String: Int],
offsetMap: RDEPUBChapterOffsetMap,
cacheKey: RDEPUBChapterCacheKey
) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: pages.count,
fragmentOffsets: fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: pages.map { .from($0.metadata) }
)
}
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }
let href = publication.spine[spineIndex].href
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
return html.rd_sha256Hex
}
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
guard let html = parser.htmlString(forRelativePath: href) else {
throw RDEPUBChapterLoadError.emptyChapterHref(href)
}
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).rd_sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
private extension RDEPUBChapterLoader.LoadPriority {
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
if lhs.rank >= rhs.rank {
return lhs
}
return rhs
}
var rank: Int {
switch self {
case .prefetch:
return 0
case .preview:
return 1
case .navigation:
return 2
}
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
switch self {
case .missingParser:
return "章节加载失败:缺少解析上下文。"
case .emptyChapter(let spineIndex):
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
case .emptyChapterHref(let href):
return "章节加载失败:未找到章节资源 \(href)"
}
}
}
@@ -0,0 +1,30 @@
import Foundation
public struct RDEPUBChapterLocation: Codable, Equatable {
public var spineIndex: Int
public var chapterOffset: Int
public var fragmentID: String?
public var progressionInChapter: Double?
public var schemaVersion: Int
public init(
spineIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
progressionInChapter: Double? = nil,
schemaVersion: Int = 2
) {
self.spineIndex = spineIndex
self.chapterOffset = chapterOffset
self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
self.progressionInChapter = progressionInChapter
self.schemaVersion = schemaVersion
}
var isFallbackEstimate: Bool { schemaVersion == 1 }
}
@@ -0,0 +1,69 @@
import Foundation
final class RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
private let cfiMapLock = NSLock()
private var _cfiMap: RDEPUBCFIMap?
var cfiMap: RDEPUBCFIMap? {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
return _cfiMap
}
let chapterText: String?
init(
fragmentOffsets: [String: Int],
pageStartOffsets: [Int],
pageEndOffsets: [Int],
cfiMap: RDEPUBCFIMap?,
chapterText: String?
) {
self.fragmentOffsets = fragmentOffsets
self.pageStartOffsets = pageStartOffsets
self.pageEndOffsets = pageEndOffsets
self._cfiMap = cfiMap
self.chapterText = chapterText
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
cfiMapLock.lock()
_cfiMap = cfiMap
cfiMapLock.unlock()
}
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
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] {
return i
}
}
return nil
}
}
@@ -0,0 +1,203 @@
import UIKit
final class RDEPUBChapterRuntimeStore {
private let chapterDataCache = RDEPUBChapterDataCache()
private let pageCountCache = RDEPUBPageCountCache()
let imageCache = NSCache<NSString, UIImage>()
let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
// (verified by audit of all 7 access points). They are not protected by locks unlike
// navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
// access remains main-thread-only. Do NOT access from chapterLoadQueue.
private(set) var currentSpineIndex: Int?
private(set) var windowSpineIndices: [Int] = []
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
private var buildingCFIMapSpineIndices: Set<Int> = []
private let cfiMapLock = NSLock()
private var pendingChapterLoadSpineIndices: Set<Int> = []
private let pendingChapterLoadLock = NSLock()
init() {
imageCache.countLimit = 50
imageCache.totalCostLimit = 104_857_600 // 100 MB
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
func assertNotOnChapterLoadQueue() {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
return pageCountCache[key]
}
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
}
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
pageCountCache[key] = pc
}
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
let lowerBound = max(0, spineIndex - radius)
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
guard lowerBound <= upperBound else {
windowSpineIndices = [spineIndex]
return
}
windowSpineIndices = Array(lowerBound...upperBound)
}
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
}
func evictAllExceptCurrent() {
guard let current = currentSpineIndex else {
chapterDataCache.removeAll()
pageCountCache.removeAll()
return
}
let currentChapter = chapterDataCache[current]
chapterDataCache.removeAll()
if let ch = currentChapter {
chapterDataCache[current] = ch
}
pageCountCache.removeAll()
}
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
pendingNavigationTarget = nil
navigationLock.unlock()
return target
}
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)
prefetchLock.unlock()
return has
}
func markBuilding(_ building: Bool) {
buildingLock.lock()
isBuilding = building
buildingLock.unlock()
}
func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
defer { pendingChapterLoadLock.unlock() }
return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
}
func endPendingChapterLoad(for spineIndex: Int) {
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.remove(spineIndex)
pendingChapterLoadLock.unlock()
}
func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
pendingChapterLoadLock.unlock()
return hasPendingLoad
}
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
return inserted
}
func endBuildingCFIMap(for spineIndex: Int) {
cfiMapLock.lock()
buildingCFIMapSpineIndices.remove(spineIndex)
cfiMapLock.unlock()
}
func invalidateAllLayoutDependentContent() {
RDEPUBMemoryProbe.log("layoutDependentContentInvalidateAll")
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
cfiMapLock.lock()
buildingCFIMapSpineIndices.removeAll()
cfiMapLock.unlock()
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.removeAll()
pendingChapterLoadLock.unlock()
}
}
@@ -0,0 +1,228 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
self.cacheDirectory = cacheDirectory
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
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 { }
}
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
let data: Data
do {
data = try Data(contentsOf: fileURL)
} 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)")
#endif
}
return nil
}
do {
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
return nil
}
}
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
) {
var summaries: [Int: RDEPUBChapterSummary] = [:]
var mapBuilder = RDEPUBBookPageMap.Builder()
for item in keys {
if let summary = read(for: item.key) {
summaries[item.spineIndex] = summary
mapBuilder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
}
return (summaries, mapBuilder)
}
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
return false
}
}
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)
}
var count = 0
var totalBytes: Int64 = 0
for fileURL in files where fileURL.pathExtension == "json" {
count += 1
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalBytes += Int64(size)
}
}
return (count, totalBytes)
}
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.rd_sha256Hex
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key)
let tmpURL = fileURL.appendingPathExtension("tmp")
do {
let data = try JSONEncoder().encode(summary)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
try? fileManager.removeItem(at: tmpURL)
}
}
private func removeFiles(matching predicate: (String) -> Bool) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.rd_sha256Hex.prefix(12).lowercased()
}
}
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 = 17
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 {
RDEPUBTextPageMetadata(
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges.map { $0.nsRange },
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
PageMetadataSummary(
breakReason: metadata.breakReason.rawValue,
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
blockKinds: metadata.blockKinds.map { $0.rawValue },
semanticHints: metadata.semanticHints.map { $0.rawValue },
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
trailingFragmentID: metadata.trailingFragmentID
)
}
}
}
@@ -0,0 +1,679 @@
import Foundation
final class RDEPUBChapterWarmupOrchestrator {
private unowned let context: RDEPUBReaderContext
private unowned let store: RDEPUBChapterRuntimeStore
private unowned let loader: RDEPUBChapterLoader
private unowned let presentationRuntime: RDEPUBPresentationRuntime
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private let refreshVisibleContentPreservingLocation: () -> Void
private let asyncLoadStateLock = NSLock()
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
private var isExtendingPartialBookPageMap = false
private let prepareRequestStateLock = NSLock()
private var pendingPreparePageNumbers: Set<Int> = []
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
private static let upcomingChapterLookaheadCount = 2
init(
context: RDEPUBReaderContext,
store: RDEPUBChapterRuntimeStore,
loader: RDEPUBChapterLoader,
presentationRuntime: RDEPUBPresentationRuntime,
locationCoordinator: RDEPUBReaderLocationCoordinator,
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
jumpSessionManager: RDEPUBJumpSessionManager,
refreshVisibleContentPreservingLocation: @escaping () -> Void
) {
self.context = context
self.store = store
self.loader = loader
self.presentationRuntime = presentationRuntime
self.locationCoordinator = locationCoordinator
self.backgroundPriorityManager = backgroundPriorityManager
self.jumpSessionManager = jumpSessionManager
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
}
@discardableResult
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
guard let bookPageMap = context.bookPageMap,
let publication = context.publication else {
return false
}
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
let chapterReady = store.chapterData(for: spineIndex) != nil
if let debouncedResult = debouncedPrepareResult(
pageNumber: pageNumber,
spineIndex: spineIndex,
chapterReady: chapterReady,
allowSynchronousLoad: allowSynchronousLoad
) {
return debouncedResult
}
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
if !chapterReady {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
spineIndex: spineIndex,
triggerPageNumber: pageNumber,
completion: completion
)
return false
}
do {
_ = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
} catch {
clearPendingPreparePageNumber(pageNumber)
return false
}
}
markPrepareResolved(pageNumber)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
return true
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap else {
return
}
let buildableSpineIndices = buildableSpineIndices(in: publication)
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
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
}
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
return
}
let currentLocation = locationCoordinator.currentVisibleLocation()
let loadedChaptersLock = NSLock()
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
let group = DispatchGroup()
for spineIndex in spineIndicesToAppend {
group.enter()
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .prefetch
) { result in
defer { group.leave() }
switch result {
case .success(let chapter):
loadedChaptersLock.lock()
loadedChapters[spineIndex] = chapter
loadedChaptersLock.unlock()
case .failure:
break
}
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
defer { self.endPartialBookPageMapExtension() }
// Use live position values rather than the stale captured values,
// since the user may have turned several pages since the extension began.
let livePageNumber = max(self.context.readerView?.currentPage ?? 0, 0) + 1
let liveLocation = self.locationCoordinator.currentVisibleLocation()
self.applyAsyncPartialBookPageMapExtension(
currentPageNumber: livePageNumber,
currentLocation: liveLocation,
currentMap: currentMap,
loadedChapters: loadedChapters
)
}
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
store.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if store.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
store.addPrefetchTarget(spineIndex)
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
@discardableResult
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
guard context.bookPageMap != nil,
let publication = context.publication,
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
return false
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
} else {
false
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
if context.pendingPageMapUpdates.contains(where: { update in
update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
}) {
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableIndices = buildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else { return false }
store.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
context.readerView?.reloadData()
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
}
func clear() {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.removeAll()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
prepareRequestStateLock.lock()
pendingPreparePageNumbers.removeAll()
recentPrepareTimestamps.removeAll()
prepareRequestStateLock.unlock()
}
private func applyAsyncPartialBookPageMapExtension(
currentPageNumber: Int,
currentLocation: RDEPUBLocation?,
currentMap: RDEPUBBookPageMap,
loadedChapters: [Int: RDEPUBRuntimeChapter]
) {
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
guard let chapter = loadedChapters[spineIndex] else { return nil }
return 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 combinedEntries = (currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
var absolutePageStart = 0
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
let normalized = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalized
}
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
presentationRuntime.queueExtendedPartialPageMap(
newMap,
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
}
private func scheduleAsynchronousChapterPreparation(
spineIndex: Int,
triggerPageNumber: Int,
completion: ((Bool) -> Void)?
) {
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
return
}
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .preview
) { [weak self] result in
guard let self else { return }
self.endAsynchronousChapterPreparation(for: spineIndex)
switch result {
case .success:
self.markPrepareResolved(triggerPageNumber)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
case .failure(let error):
self.clearPendingPreparePageNumber(triggerPageNumber)
completion?(false)
}
}
}
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
// otherwise the two policies evict/rebuild the same chapter in a loop.
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
store.evict(spineIndex: evictable)
}
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
store.addPrefetchTarget(adjacentSpineIndex)
loader.loadChapter(
spineIndex: adjacentSpineIndex,
store: store,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
guard let publication = context.publication else { return [] }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
}
private func maybePrefetchUpcomingChapters(
aroundAbsolutePageNumber pageNumber: Int,
in bookPageMap: RDEPUBBookPageMap,
threshold: Int = 3,
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
) {
guard let publication = context.publication else { return }
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex) else {
return
}
let remainingPages = chapter.pages.count - localPageIndex - 1
guard remainingPages <= threshold else { return }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
for targetSpineIndex in targets {
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
store.addPrefetchTarget(targetSpineIndex)
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
minimumTrailingPages: Int = 2
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
return
}
let currentPageNumber = max(readerView.currentPage + 1, 1)
let trailingPages = currentMap.totalPages - currentPageNumber
guard trailingPages <= minimumTrailingPages else { return }
let buildableIndices = buildableSpineIndices(in: publication)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
var projectedTotalPages = currentMap.totalPages
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = store.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
)
)
projectedTotalPages += chapter.pages.count
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
break
}
}
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 }
presentationRuntime.queueForwardAppendedPageMap(newMap)
}
private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
guard store.chapterData(for: spineIndex) == nil else { return false }
guard !store.hasPrefetchTarget(spineIndex) else { return false }
guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
return true
}
private func debouncedPrepareResult(
pageNumber: Int,
spineIndex: Int,
chapterReady: Bool,
allowSynchronousLoad: Bool
) -> Bool? {
prepareRequestStateLock.lock()
defer { prepareRequestStateLock.unlock() }
let now = CFAbsoluteTimeGetCurrent()
recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
if !allowSynchronousLoad && !chapterReady {
let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
if !inserted {
return false
}
return nil
}
pendingPreparePageNumbers.remove(pageNumber)
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
now - lastTimestamp <= prepareRequestDebounceInterval {
return chapterReady
}
recentPrepareTimestamps[pageNumber] = now
return nil
}
private func markPrepareResolved(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
prepareRequestStateLock.unlock()
}
private func clearPendingPreparePageNumber(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
prepareRequestStateLock.unlock()
}
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
guard let readerView = context.readerView,
let bookPageMap = context.bookPageMap else {
return
}
if readerView.isPageCurlTransitioning {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.refreshVisibleContentIfNeeded(
afterPreparing: spineIndex,
triggerPageNumber: triggerPageNumber
)
}
return
}
let visiblePageNumber = readerView.currentPage + 1
if visiblePageNumber == triggerPageNumber {
refreshVisibleContentPreservingLocation()
return
}
guard visiblePageNumber > 0,
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
visibleSpineIndex == spineIndex else {
return
}
refreshVisibleContentPreservingLocation()
}
// Intentional synchronous path: called from TOC distant jumps where
// the user expects immediate navigation. The loading indicator is shown
// by the caller. Do NOT convert to async without UX consideration.
private func loadPartialWindowChapters(
around anchorPosition: Int,
in buildableSpineIndices: [Int],
targetSpineIndex: Int,
windowSize: Int
) -> [RDEPUBRuntimeChapter] {
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
let startIndex = max(0, upperBound - max(windowSize, 1))
let window = Array(buildableSpineIndices[startIndex..<upperBound])
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in window {
do {
let chapter = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
chapters.append(chapter)
} catch {
if spineIndex == targetSpineIndex {
return []
}
}
}
return chapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
}
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
}
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.remove(spineIndex)
asyncLoadStateLock.unlock()
}
private func beginPartialBookPageMapExtension() -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
guard !isExtendingPartialBookPageMap else { return false }
isExtendingPartialBookPageMap = true
return true
}
private func endPartialBookPageMapExtension() {
asyncLoadStateLock.lock()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
}
}
@@ -0,0 +1,33 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
get {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
set {
lock.lock()
defer { lock.unlock() }
storage[key] = newValue
}
}
func remove(forSpineIndex spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage = storage.filter { $0.value.spineIndex != spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
}
}
@@ -0,0 +1,40 @@
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) {
self.context = context
self.store = store
}
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
guard let bookPageMap = context.bookPageMap,
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex),
chapter.pages.indices.contains(localPageIndex),
let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
return nil
}
var page = chapter.pages[localPageIndex]
page.absolutePageIndex = absolutePageIndex
page.chapterIndex = chapterIndex
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
}
}
@@ -0,0 +1,52 @@
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(
spineIndex: Int,
href: String,
title: String,
sourceAttributedString: NSAttributedString?,
typesetAttributedString: NSAttributedString,
layouter: RDEPUBTextLayouter,
pageRanges: [NSRange],
pages: [RDEPUBTextPage],
chapterOffsetMap: RDEPUBChapterOffsetMap
) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.sourceAttributedString = sourceAttributedString
self.typesetAttributedString = typesetAttributedString
self.layouter = layouter
self.pageRanges = pageRanges
self.pages = pages
self.chapterOffsetMap = chapterOffsetMap
}
func releaseSourceText() {
sourceAttributedString = nil
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
chapterOffsetMap.updateCFIMap(cfiMap)
}
}
@@ -0,0 +1,14 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}
@@ -0,0 +1,252 @@
import Foundation
struct RDEPUBBackgroundCoverageSegment {
let lowerSpineIndex: Int
let upperSpineIndex: Int
let pageMap: RDEPUBBookPageMap
let resolvedSpineIndices: Set<Int>
let generatedAt: CFAbsoluteTime
let renderSignature: String
let estimatedMemoryBytes: Int
func contains(spineIndex: Int) -> Bool {
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
}
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
)
}
final class RDEPUBBackgroundCoverageStore {
private unowned let context: RDEPUBReaderContext
private let policy: RDEPUBBackgroundCoverageStorePolicy
private var segments: [RDEPUBBackgroundCoverageSegment] = []
private var currentMemoryBytes: Int = 0
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
self.context = context
self.policy = policy
}
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
evictIfNeeded(forNewSegment: segment)
var merged = false
for (index, existing) in segments.enumerated() {
if canMerge(existing, segment) {
if let mergedSegment = mergeSegments(existing, segment) {
segments[index] = mergedSegment
currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes
merged = true
break
}
}
}
if !merged {
segments.append(segment)
currentMemoryBytes += segment.estimatedMemoryBytes
}
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
}
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
if let segment {
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
}
return segment
}
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { segment in
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
}
if let segment {
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
}
return segment
}
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
segments
}
func clearAll() {
segments.removeAll()
currentMemoryBytes = 0
lastAccessTime.removeAll()
}
func clearColdSegments(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
) {
let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices)
segments.removeAll { segment in
let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) })
if isCold {
currentMemoryBytes -= segment.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
}
return isCold
}
}
func handleMemoryWarning(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
) {
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
}.min() ?? Int.max
let rhsDistance = rhs.resolvedSpineIndices.map { idx in
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
}.min() ?? Int.max
return lhsDistance > rhsDistance
}
for segment in sorted {
if currentMemoryBytes <= policy.memoryBudgetBytes { break }
currentMemoryBytes -= segment.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex }
}
}
}
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() {
let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0
if accessTime < oldestTime {
oldestTime = accessTime
oldestIndex = index
}
}
let evicted = segments.remove(at: oldestIndex)
currentMemoryBytes -= evicted.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex)
}
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
}
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)
return RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: newLower,
upperSpineIndex: newUpper,
pageMap: newPageMap,
resolvedSpineIndices: newResolved,
generatedAt: max(lhs.generatedAt, rhs.generatedAt),
renderSignature: lhs.renderSignature,
estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count)
)
}
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
for entry in older.entries {
entriesBySpineIndex[entry.spineIndex] = entry
}
for entry in newer.entries {
entriesBySpineIndex[entry.spineIndex] = entry
}
for spineIndex in entriesBySpineIndex.keys.sorted() {
guard let entry = entriesBySpineIndex[spineIndex] else { continue }
builder.add(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
fragmentOffsets: entry.fragmentOffsets
)
}
return builder.build()
}
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
256 + pageMap.entries.count * 96 + resolvedCount * 16
}
}
@@ -0,0 +1,178 @@
import Foundation
struct RDEPUBBackgroundPriorityPolicy {
let hotRadius: Int
let warmRadius: Int
let maxWarmJumpAnchors: Int
let coldLaneShare: Double
static let `default` = RDEPUBBackgroundPriorityPolicy(
hotRadius: 24,
warmRadius: 96,
maxWarmJumpAnchors: 2,
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)
return RDEPUBBackgroundPriorityPolicy(
hotRadius: hotRadius,
warmRadius: warmRadius,
maxWarmJumpAnchors: 2,
coldLaneShare: 0.15
)
}
}
enum RDEPUBPriorityBand: Int, Comparable {
case hot = 0
case warmPrimary = 1
case warmSecondary = 2
case cold = 3
static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
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] = []
private let warmAnchorsLock = NSLock()
private(set) var currentGeneration: Int = 0
private var coldCursor: Int = 0
init(context: RDEPUBReaderContext) {
self.context = context
self.policy = .default
}
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
policy = newPolicy
}
func addWarmAnchor(spineIndex: Int) {
let anchor = RDEPUBWarmJumpAnchor(
spineIndex: spineIndex,
timestamp: CFAbsoluteTimeGetCurrent(),
sequenceNumber: currentGeneration
)
warmAnchorsLock.lock()
warmAnchors.insert(anchor, at: 0)
if warmAnchors.count > policy.maxWarmJumpAnchors {
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
}
warmAnchorsLock.unlock()
currentGeneration += 1
coldCursor = 0
}
func makeMetadataPriorityOrder(
allBuildableIndices: [Int],
currentSpineIndex: Int?,
cachedSpineIndices: Set<Int>
) -> [Int] {
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
guard !uncachedIndices.isEmpty else { return [] }
// M-03: Snapshot warmAnchors under lock since it's written on main thread
// and read on background thread.
warmAnchorsLock.lock()
let warmAnchorsSnapshot = warmAnchors
warmAnchorsLock.unlock()
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
let band = classifySpineIndex(
spineIndex: spineIndex,
currentSpineIndex: currentSpineIndex,
warmAnchors: warmAnchorsSnapshot
)
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
}
return lhs.spineIndex < rhs.spineIndex
}
return sorted.map { $0.spineIndex }
}
private func classifySpineIndex(
spineIndex: Int,
currentSpineIndex: Int?,
warmAnchors: [RDEPUBWarmJumpAnchor]
) -> RDEPUBPriorityBand {
if let current = currentSpineIndex {
let distance = abs(spineIndex - current)
if distance <= policy.hotRadius {
return .hot
}
}
for (index, anchor) in warmAnchors.enumerated() {
let distance = abs(spineIndex - anchor.spineIndex)
if distance <= policy.warmRadius {
return index == 0 ? .warmPrimary : .warmSecondary
}
}
return .cold
}
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
warmAnchorsLock.lock()
defer { warmAnchorsLock.unlock() }
return warmAnchors
}
func reset() {
warmAnchorsLock.lock()
warmAnchors.removeAll()
warmAnchorsLock.unlock()
currentGeneration = 0
coldCursor = 0
}
}
@@ -0,0 +1,191 @@
import Foundation
struct RDEPUBJumpSession {
let anchorSpineIndex: Int
let createdAt: CFAbsoluteTime
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
}
}
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,
idleGracePeriod: 1.5,
protectedNeighborRadius: 1
)
public init(
exitPageThreshold: Int = 6,
timeout: TimeInterval = 20,
idleGracePeriod: TimeInterval = 1.5,
protectedNeighborRadius: Int = 1
) {
self.exitPageThreshold = exitPageThreshold
self.timeout = timeout
self.idleGracePeriod = idleGracePeriod
self.protectedNeighborRadius = protectedNeighborRadius
}
}
final class RDEPUBJumpSessionManager {
private unowned let context: RDEPUBReaderContext
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
}
init(context: RDEPUBReaderContext) {
self.context = context
}
@discardableResult
func createSession(
anchorSpineIndex: Int,
reason: RDEPUBJumpSession.Reason,
totalSpineCount: Int
) -> RDEPUBJumpSession {
let policy = context.configuration.jumpSessionPolicy
let now = CFAbsoluteTimeGetCurrent()
var protectedIndices: Set<Int> = [anchorSpineIndex]
for offset in 1...policy.protectedNeighborRadius {
let lower = anchorSpineIndex - offset
let upper = anchorSpineIndex + offset
if lower >= 0 {
protectedIndices.insert(lower)
}
if upper < totalSpineCount {
protectedIndices.insert(upper)
}
}
nextSequenceNumber += 1
let session = RDEPUBJumpSession(
anchorSpineIndex: anchorSpineIndex,
createdAt: now,
protectedSpineIndices: protectedIndices,
sequenceNumber: nextSequenceNumber,
expiresAt: now + policy.timeout,
reason: reason
)
activeSession = session
consecutivePageCount = 0
lastPageDirection = nil
lastActivityTime = now
return session
}
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
guard activeSession != nil else { return }
let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward
lastActivityTime = CFAbsoluteTimeGetCurrent()
if direction == lastPageDirection {
consecutivePageCount += 1
} else {
consecutivePageCount = 1
lastPageDirection = direction
}
}
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
guard let session = activeSession else {
return true
}
let protectedIndices = session.protectedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
Double(protectedIndices.count)
return coverageRatio >= 0.8
}
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
guard let session = activeSession else { return nil }
let now = CFAbsoluteTimeGetCurrent()
let policy = context.configuration.jumpSessionPolicy
if now >= session.expiresAt {
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
return .timeout
}
}
if !session.protectedSpineIndices.contains(currentSpineIndex) {
if consecutivePageCount >= policy.exitPageThreshold {
return .navigatedAway
}
} else {
consecutivePageCount = 0
}
return nil
}
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
guard let session = activeSession else { return }
activeSession = nil
consecutivePageCount = 0
lastPageDirection = nil
}
func clearSession() {
activeSession = nil
consecutivePageCount = 0
lastPageDirection = nil
nextSequenceNumber = 0
}
}
@@ -0,0 +1,48 @@
import Foundation
/// Memory footprint probe for the long-chapter optimization work
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the
/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter
/// load, every 20 page turns, settings invalidation, and rotation.
enum RDEPUBMemoryProbe {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe")
private static let pageTurnLogStride = 20
/// Main-thread only (page turns are delivered on main).
private static var pageTurnCount = 0
static func logPageTurn() {
guard isEnabled else { return }
pageTurnCount += 1
guard pageTurnCount % pageTurnLogStride == 0 else { return }
log("pageTurn count=\(pageTurnCount)")
}
static func log(_ event: String) {
guard isEnabled else { return }
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB))
}
/// Current phys_footprint in MB. Cheap enough (one task_info call) to
/// surface in the demo state snapshot for automated memory assertions.
static var footprintMB: Double {
Double(currentFootprint()) / 1_048_576
}
/// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
private static func currentFootprint() -> UInt64 {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let result = withUnsafeMutablePointer(to: &info) { pointer in
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard result == KERN_SUCCESS else { return 0 }
return info.phys_footprint
}
}
@@ -0,0 +1,44 @@
import Foundation
final class RDEPUBMetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
self.token = token
}
func attach(queue: OperationQueue) {
let shouldCancelImmediately: Bool
lock.lock()
self.queue = queue
shouldCancelImmediately = cancelled
lock.unlock()
if shouldCancelImmediately {
queue.cancelAllOperations()
}
}
func cancel() {
let queueToCancel: OperationQueue?
lock.lock()
cancelled = true
queueToCancel = queue
lock.unlock()
queueToCancel?.cancelAllOperations()
}
var isCancelled: Bool {
lock.lock()
let value = cancelled
lock.unlock()
return value
}
}
@@ -0,0 +1,604 @@
import Foundation
final class RDEPUBMetadataParseWorker {
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
private final class ParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
totalResolvedCount: Int,
lastAppliedCount: Int
) {
self.summariesBySpineIndex = summariesBySpineIndex
self.totalResolvedCount = totalResolvedCount
self.lastAppliedCount = lastAppliedCount
}
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
weak var context: RDEPUBReaderContext?
let cancellationController: RDEPUBMetadataParseCancellationController
let pageMapRefreshInterval: Int
private let token: UUID
private let parser: RDEPUBParser
private let publication: RDEPUBPublication
private let pageSize: CGSize
private let layoutConfig: RDEPUBTextLayoutConfig
private let style: RDEPUBTextRenderStyle
private let renderSignature: String
private let allBuildableIndices: [Int]
private let summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let workerCount: Int
private let contentHashBySpineIndex: [Int: String]
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
private let progressLogStride = 4
init(
context: RDEPUBReaderContext,
cancellationController: RDEPUBMetadataParseCancellationController,
token: UUID,
parser: RDEPUBParser,
publication: RDEPUBPublication
) {
self.context = context
self.cancellationController = cancellationController
self.token = token
self.parser = parser
self.publication = publication
let pageSize = context.currentTextPageSize()
self.pageSize = pageSize
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
self.style = context.currentTextRenderStyle()
self.renderSignature = context.currentRenderSignature()
self.allBuildableIndices = publication.spine.indices.filter { index in
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
self.summaryDiskCache = context.runtime?.summaryDiskCache
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
var hashes: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
hashes[spineIndex] = ""
continue
}
hashes[spineIndex] = html.rd_sha256Hex
}
self.contentHashBySpineIndex = hashes
let ctx = context
let spine = publication.spine
let sig = renderSignature
self.catalog = allBuildableIndices.map { spineIndex in
let item = spine[spineIndex]
return (
key: ctx.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: hashes[spineIndex] ?? "",
renderSignature: sig
),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
}
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
let cancellationController = self.cancellationController
DispatchQueue.global(qos: .utility).async { [self] in
RDEPUBBackgroundTrace.log(
"Metadata",
"workerDispatched token=\(token.uuidString)"
)
let context = self.context
guard let context,
context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
)
return
}
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
)
return
}
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
RDEPUBBackgroundTrace.log(
"Metadata",
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock()
let parseState = ParseState(
summariesBySpineIndex: cachedSummaries,
totalResolvedCount: cachedSpineIndices.count,
lastAppliedCount: cachedSpineIndices.count
)
if !cachedSpineIndices.isEmpty {
let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
allBuildableIndices: self.allBuildableIndices,
currentSpineIndex: currentSpineIndex,
cachedSpineIndices: cachedSpineIndices
)
} else {
prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
}
let uncachedSpineIndices = prioritizedSpineIndices
RDEPUBBackgroundTrace.log(
"Metadata",
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
)
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
guard !cancellationController.isCancelled,
context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedDuringCooldown"
)
return
}
RDEPUBBackgroundTrace.log(
"Metadata",
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
)
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
let queue = OperationQueue()
queue.name = "com.RDEpubReader.metadata.parse"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = self.workerCount
cancellationController.attach(queue: queue)
let refreshInterval = self.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
do {
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter(
parser: self.parser,
publication: self.publication,
spineIndex: spineIndex,
pageSize: self.pageSize,
style: self.style
) else {
return nil
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: self.renderSignature
)
let summary = RDEPUBChapterSummary(
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,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let writeStart = CFAbsoluteTimeGetCurrent()
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
timingLock.lock()
totalRenderMs += renderElapsed
totalWriteMs += writeElapsed
completedChapters += 1
timingLock.unlock()
return summary
}
guard let renderResult else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1
let resolvedCount = parseState.totalResolvedCount
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|| resolvedCount == cachedSpineIndices.count + 1
|| resolvedCount % self.progressLogStride == 0
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount
snapshot = parseState.summariesBySpineIndex
}
resultLock.unlock()
if shouldLogProgress {
let progressPercent = Self.progressPercent(
resolved: resolvedCount,
total: self.allBuildableIndices.count
)
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
)
}
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(summaries: snapshot)
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil,
operation?.isCancelled != true else {
return
}
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
)
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
resultLock: resultLock,
parseState: parseState,
cancellationController: cancellationController
)
}
}
queue.addOperation(operation)
}
queue.waitUntilAllOperationsAreFinished()
if !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil {
self.summaryDiskCache?.flushPendingWrites()
}
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = self.workerCount
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"Metadata",
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
)
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
let upperSpine = resolvedSpineIndices.max() ?? 0
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
let segment = RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: lowerSpine,
upperSpineIndex: upperSpine,
pageMap: pageMap,
resolvedSpineIndices: resolvedSpineIndices,
generatedAt: CFAbsoluteTimeGetCurrent(),
renderSignature: self.renderSignature,
estimatedMemoryBytes: estimatedBytes
)
coverageStore.addSegment(segment)
}
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
// MARK: - Private
private func waitForReadingInteractionToSettle(
cancellationController: RDEPUBMetadataParseCancellationController? = nil
) {
let semaphore = DispatchSemaphore(value: 0)
let maxWaitIterations = 100 // ~8 seconds max wait
var iteration = 0
while context?.controller != nil,
cancellationController?.isCancelled != true,
iteration < maxWaitIterations {
let elapsed = context?.secondsSinceLastUserNavigation() ?? 0
if elapsed >= backgroundInteractionCooldown { break }
semaphore.wait(timeout: .now() + 0.08)
iteration += 1
}
}
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
resultLock: NSLock,
parseState: ParseState,
cancellationController: RDEPUBMetadataParseCancellationController
) {
guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"Metadata",
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
)
return
}
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"Metadata",
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, let context = self.context else { return }
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
return
}
do {
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
guard let result = try chapterBuilder.buildChapter(
parser: self.parser,
publication: self.publication,
spineIndex: spineIndex,
pageSize: self.pageSize,
style: self.style
) else {
return
}
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
return
}
let chapter = result.chapter
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: self.renderSignature
)
let summary = RDEPUBChapterSummary(
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,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
return
}
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = summary
parseState.totalResolvedCount += 1
let shouldRefresh =
parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count
if shouldRefresh {
parseState.lastAppliedCount = parseState.totalResolvedCount
}
resultLock.unlock()
if shouldRefresh {
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
RDEPUBBackgroundTrace.log(
"Metadata",
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == self.token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
resultLock: resultLock,
parseState: parseState,
cancellationController: cancellationController
)
}
}
}
private static func progressPercent(resolved: Int, total: Int) -> Int {
guard total > 0 else { return 0 }
return Int((Double(resolved) / Double(total) * 100.0).rounded())
}
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
guard let summaryDiskCache else { return nil }
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
private func buildPageMap(
summaries: [Int: RDEPUBChapterSummary]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
guard let summary = summaries[item.spineIndex] else { continue }
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
return builder.build()
}
}
@@ -0,0 +1,230 @@
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)
}
return .keepCurrentWindow
}
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let lastBuildableSpineIndex = context.publication?.spine.indices
.reversed()
.first(where: { index in
guard let item = context.publication?.spine[index] else { return false }
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}) ?? 0
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) /
Double(protectedIndices.count)
if coverageRatio < 0.8 {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)"
)
return .keepCurrentWindow
}
}
}
}
if let currentSpineIndex {
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
if requiresAdjacentCoverage {
if let candidateSegment {
let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1)
let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1)
if !hasPrev || !hasNext {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
)
return .keepCurrentWindow
}
}
}
}
if let candidateSegment {
let currentRenderSignature = context.currentRenderSignature()
if candidateSegment.renderSignature != currentRenderSignature {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)"
)
return .keepCurrentWindow
}
}
if let candidateSegment {
return evaluateSegmentTakeover(
candidateSegment: candidateSegment,
currentWindow: currentWindow,
currentSpineIndex: currentSpineIndex,
lastBuildableSpineIndex: lastBuildableSpineIndex
)
}
if let candidatePageMap {
return evaluateFullPageMapTakeover(
candidatePageMap: candidatePageMap,
currentWindow: currentWindow,
currentSpineIndex: currentSpineIndex,
lastBuildableSpineIndex: lastBuildableSpineIndex
)
}
return .keepCurrentWindow
}
private func evaluateSegmentTakeover(
candidateSegment: RDEPUBBackgroundCoverageSegment,
currentWindow: RDEPUBBookPageMap,
currentSpineIndex: Int?,
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
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) ||
currentSpineIndex == lastBuildableSpineIndex
if !hasPrev || !hasNext {
return .keepCurrentWindow
}
}
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)
}
}
return .keepCurrentWindow
}
private func evaluateFullPageMapTakeover(
candidatePageMap: RDEPUBBookPageMap,
currentWindow: RDEPUBBookPageMap,
currentSpineIndex: Int?,
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let currentEntries = currentWindow.entries.count
if let currentSpineIndex {
if !candidateIndices.contains(currentSpineIndex) {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)"
)
return .keepCurrentWindow
}
}
if let currentSpineIndex {
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
currentSpineIndex == lastBuildableSpineIndex
if !hasPrev || !hasNext {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
)
return .keepCurrentWindow
}
}
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
if coversCurrentWindow {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
)
return .fullReplace(candidatePageMap)
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
)
return .keepCurrentWindow
}
func protectedSpineIndices(
currentSpineIndex: Int?,
jumpSession: RDEPUBJumpSession?
) -> Set<Int> {
var indices: Set<Int> = []
if let currentSpineIndex {
indices.insert(currentSpineIndex)
if currentSpineIndex > 0 {
indices.insert(currentSpineIndex - 1)
}
indices.insert(currentSpineIndex + 1)
}
if let jumpSession {
indices.formUnion(jumpSession.protectedSpineIndices)
}
return indices
}
}
@@ -0,0 +1,471 @@
import UIKit
enum RDEPUBPendingPageMapUpdateKind {
case reconcileFullMap
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
case appendForward
}
struct RDEPUBPendingPageMapUpdate {
let pageMap: RDEPUBBookPageMap
let kind: RDEPUBPendingPageMapUpdateKind
}
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
private var pendingCommitRetryWorkItem: DispatchWorkItem?
init(
context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator,
jumpSessionManager: RDEPUBJumpSessionManager,
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
) {
self.context = context
self.locationCoordinator = locationCoordinator
self.jumpSessionManager = jumpSessionManager
self.reconciliationCoordinator = reconciliationCoordinator
}
func applyBookPageMap(
_ bookPageMap: RDEPUBBookPageMap,
restoreLocation: RDEPUBLocation?,
finishPagination: (RDEPUBLocation?) -> Void
) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
)
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .reconcileFullMap
)
)
}
func commitPendingPageMapUpdateIfNeeded() {
guard let readerView = context.readerView,
let controller = context.controller else { return }
guard !context.pendingPageMapUpdates.isEmpty else {
cancelPendingCommitRetry()
return
}
guard !controller.isRepaginating else {
schedulePendingCommitRetry()
return
}
guard !readerView.isPageCurlTransitioning else {
schedulePendingCommitRetry()
return
}
cancelPendingCommitRetry()
let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate(
update,
at: index,
readerView: readerView,
controller: controller
) {
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
return
}
}
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
}
func queueExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .extendPartial(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
)
)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
}
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .appendForward
)
)
}
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
EPUBPage(
spineIndex: entry.spineIndex,
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: entry.pageCount,
chapterTitle: entry.title,
fixedSpread: nil
)
}
}
let chapters = bookPageMap.entries.map { entry in
EPUBChapterInfo(
spineIndex: entry.spineIndex,
title: entry.title,
pageCount: entry.pageCount
)
}
return (pages, chapters)
}
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDEpubReaderView,
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
let livePageBeforeApply = readerView.currentPage + 1
// Resolved against the outgoing page map. When it round-trips to the live
// page, the location faithfully describes what is on screen, so whatever
// page it resolves to in the new map is authoritative even if the two maps
// number pages differently (partial-window -> full-book takeover).
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
context.textBook = nil
applyPageMapToLiveModel(newPageMap)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
)
if let currentLocation {
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
resolvedTargetPage,
livePageBeforeApply: livePageBeforeApply,
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
)
if shouldTrustResolvedLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return
}
let fallbackPage = max(livePageBeforeApply, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
)
rebindVisiblePage(
to: fallbackPage - 1,
readerView: readerView
)
} else {
readerView.reloadPageCountOnly()
}
if let currentLocation,
context.normalizedSpineIndex(for: currentLocation) != nil,
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
if protectedIndices.isSubset(of: candidateIndices) {
jumpSessionManager.endSession(.coverageComplete)
}
}
}
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
var updates = context.pendingPageMapUpdates
if let existingIndex = updates.firstIndex(where: {
pendingPageMapUpdateKindMatches($0.kind, update.kind)
}) {
let existing = updates[existingIndex]
if shouldReplacePendingPageMapUpdate(existing, with: update) {
updates[existingIndex] = update
}
} else {
updates.append(update)
}
context.pendingPageMapUpdates = updates
commitPendingPageMapUpdateIfNeeded()
}
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
}
}
private func commitPendingPageMapUpdate(
_ update: RDEPUBPendingPageMapUpdate,
at index: Int,
readerView: RDEpubReaderView,
controller: RDEPUBReaderController
) -> Bool {
switch update.kind {
case .reconcileFullMap:
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: update.pageMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log(
"Reconciliation",
"commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)"
)
removePendingPageMapUpdate(at: index)
return false
case .fullReplace(let newPageMap):
removePendingPageMapUpdate(at: index)
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
return true
case .expandWindow, .segmentReplace:
removePendingPageMapUpdate(at: index)
return false
}
case .extendPartial(let capturedPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap)
let livePageNumber = max(readerView.currentPage, 0) + 1
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
)
if shouldTrustCapturedLocation,
let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true
}
// Prefer the live readerView page when the user has moved since the
// extension request was created; otherwise a stale captured location can
// snap pageCurl back to the previous page after the turn completes.
let livePageIndex = max(readerView.currentPage, 0)
rebindVisiblePage(
to: livePageIndex,
readerView: readerView
)
return true
case .appendForward:
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap)
readerView.reloadPageCountOnly()
return true
}
}
private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) {
if pageIndex == readerView.currentPage {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
)
readerView.reloadPageCountOnly()
return
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
)
if readerView.currentDisplayType == .pageCurl {
if readerView.isPageCurlTransitioning {
// Defer the transition until the current page-curl animation completes,
// and re-read the live page at that time to avoid jumping to a stale position.
DispatchQueue.main.async { [weak readerView] in
guard let readerView, !readerView.isPageCurlTransitioning else { return }
let livePageIndex = max(readerView.currentPage, 0)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
)
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
}
} else {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
} else {
readerView.reloadPageCountOnly()
if pageIndex != readerView.currentPage {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
}
}
private func rebindVisibleLocation(
_ location: RDEPUBLocation,
readerView: RDEpubReaderView,
controller: RDEPUBReaderController
) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
)
return false
}
if context.bookPageMap != nil,
context.runtime?.prepareOnDemandChapter(
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: false
) == false {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
)
return false
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
)
rebindVisiblePage(
to: max(targetPageNumber - 1, 0),
readerView: readerView
)
return true
}
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
context.bookPageMap = pageMap
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
}
private func removePendingPageMapUpdate(at index: Int) {
var updates = context.pendingPageMapUpdates
guard updates.indices.contains(index) else { return }
updates.remove(at: index)
context.pendingPageMapUpdates = updates
}
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
let updates = context.pendingPageMapUpdates.filter { update in
update.pageMap.totalChapters > liveMap.totalChapters
|| (
update.pageMap.totalChapters == liveMap.totalChapters
&& update.pageMap.totalPages > liveMap.totalPages
)
}
context.pendingPageMapUpdates = updates
}
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
switch kind {
case .extendPartial:
return 3
case .appendForward:
return 2
case .reconcileFullMap:
return 1
}
}
private func pendingPageMapUpdateKindMatches(
_ lhs: RDEPUBPendingPageMapUpdateKind,
_ rhs: RDEPUBPendingPageMapUpdateKind
) -> Bool {
switch (lhs, rhs) {
case (.reconcileFullMap, .reconcileFullMap),
(.appendForward, .appendForward),
(.extendPartial, .extendPartial):
return true
default:
return false
}
}
private func shouldReplacePendingPageMapUpdate(
_ existing: RDEPUBPendingPageMapUpdate,
with candidate: RDEPUBPendingPageMapUpdate
) -> Bool {
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|| (
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
private func shouldTrustFullReplaceResolvedPage(
_ resolvedTargetPage: Int?,
livePageBeforeApply: Int,
locationMatchesLivePage: Bool
) -> Bool {
guard let resolvedTargetPage else { return false }
if locationMatchesLivePage {
return true
}
// The location did not round-trip to the live page in the outgoing map
// (stale persisted location or mid-transition), so only follow it when it
// stays next to the page the user is actually looking at.
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
}
private func schedulePendingCommitRetry() {
guard pendingCommitRetryWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingCommitRetryWorkItem = nil
self.commitPendingPageMapUpdateIfNeeded()
}
pendingCommitRetryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
}
private func cancelPendingCommitRetry() {
pendingCommitRetryWorkItem?.cancel()
pendingCommitRetryWorkItem = nil
}
}
@@ -0,0 +1,578 @@
import UIKit
final class RDEPUBReaderAnnotationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
return controller.activeBookmarks.first { $0.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))
} else {
applySelectionState(.idle)
}
}
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
context.selectionState = state
switch state {
case .idle:
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: nil)
case .selecting:
break
case .selected(let selection):
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
break
}
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection
guard let sourceSelection,
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
!scopedSelection.isEmpty else {
return nil
}
let newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: style,
color: color,
note: note
)
let isDuplicate = controller.activeHighlights.contains { highlight in
highlight.location.href == newHighlight.location.href &&
highlight.location.fragment == newHighlight.location.fragment &&
highlight.text == newHighlight.text &&
highlight.rangeInfo == newHighlight.rangeInfo &&
highlight.style == newHighlight.style
}
guard !isDuplicate else {
return nil
}
controller.activeHighlights.append(newHighlight)
persistHighlightsAndRefreshContent()
updateCurrentSelection(nil)
return newHighlight
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let scopedHighlight = scopedHighlight(highlight) else {
return nil
}
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
controller.activeHighlights[index] = scopedHighlight
} else {
controller.activeHighlights.append(scopedHighlight)
}
persistHighlightsAndRefreshContent()
return scopedHighlight
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeHighlights.remove(at: index)
persistHighlightsAndRefreshContent()
return removed
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
controller.activeHighlights[index].note = normalizedNote(note)
persistHighlightsAndRefreshContent()
return controller.activeHighlights[index]
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let highlight = highlight(withID: id) else {
return false
}
return navigate(to: highlight, animated: animated)
}
func removeAllHighlights() {
guard let controller else { return }
guard !controller.activeHighlights.isEmpty else { return }
controller.activeHighlights.removeAll()
persistHighlightsAndRefreshContent()
}
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
selection.location,
relativeToSpineIndex: spineIndex,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: selection.location.href,
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor,
cfi: selection.location.cfi,
lastCFI: selection.location.lastCFI,
rangeCFI: selection.location.rangeCFI
)
return RDEPUBSelection(
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return }
let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights,
theme: controller.configuration.theme,
sectionTitleProvider: { [weak self] highlight in
self?.titleForHighlight(highlight)
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
highlightsController?.dismiss(animated: true) {
_ = self?.navigate(to: highlight, animated: true)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
}
highlightsController.onDeleteHighlight = { [weak self] highlight in
_ = self?.controller?.removeHighlight(id: highlight.id)
}
let navigationController = UINavigationController(rootViewController: highlightsController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func presentAnnotationCreation() {
guard let controller else { return }
guard controller.configuration.allowsHighlights,
let currentSelection = controller.currentSelection else {
return
}
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)
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
_ = self?.removeHighlight(id: highlight.id)
})
if highlight.hasNote {
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = sourceView
popover.sourceRect = sourceRect
}
controller.present(alert, animated: true)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
case .copy:
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .highlight)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
guard bookmark(matching: location) == nil else {
return nil
}
let newBookmark = RDEPUBBookmark(
bookIdentifier: controller.currentBookIdentifier,
location: location,
rangeInfo: controller.currentVisibleRangeInfo(),
chapterTitle: titleForBookmarkLocation(location),
note: normalizedBookmarkNote(note)
)
controller.activeBookmarks.append(newBookmark)
persistBookmarksAndRefreshChrome()
return newBookmark
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
if let existingBookmark = bookmark(matching: location) {
_ = removeBookmark(id: existingBookmark.id)
return nil
}
return addBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeBookmarks.remove(at: index)
persistBookmarksAndRefreshChrome()
return removed
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let bookmark = bookmark(withID: id) else {
return false
}
return controller.restoreReadingLocation(
bookmark.location,
animated: animated,
targetHighlightRangeInfo: bookmark.rangeInfo
)
}
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks,
theme: controller.configuration.theme
)
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
guard let controller = self?.controller else { return }
bookmarksController?.dismiss(animated: true) {
_ = controller.restoreReadingLocation(
bookmark.location,
animated: true,
targetHighlightRangeInfo: bookmark.rangeInfo
)
}
}
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
_ = self?.controller?.removeBookmark(id: bookmark.id)
}
let navigationController = UINavigationController(rootViewController: bookmarksController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
highlight.location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: highlight.location.href,
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor,
cfi: highlight.location.cfi,
lastCFI: highlight.location.lastCFI,
rangeCFI: highlight.location.rangeCFI
)
return RDEPUBHighlight(
id: highlight.id,
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
style: highlight.style,
color: highlight.color,
note: highlight.note,
createdAt: highlight.createdAt
)
}
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
let navigationTarget = scopedHighlight(highlight) ?? highlight
return controller.restoreReadingLocation(
navigationTarget.location,
animated: animated,
targetHighlightRangeInfo: navigationTarget.rangeInfo
)
}
private func persistHighlightsAndRefreshContent() {
guard let controller else { return }
if let currentBookIdentifier = controller.currentBookIdentifier {
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
}
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
controller.updateReaderChrome()
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) {
guard let controller else { return }
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .highlight)
})
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .underline)
})
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = controller.bottomToolView
popover.sourceRect = controller.bottomToolView.bounds
}
controller.present(alert, animated: true)
}
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
_ = addAnnotation(from: selection, style: style, note: note)
}
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
guard let controller else { return }
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
self?.createAnnotation(
from: selection,
style: .highlight,
note: alert?.textFields?.first?.text
)
})
controller.present(alert, animated: true)
}
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
guard let controller else { return nil }
guard let publication = controller.publication,
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return nil
}
return controller.flattenedTableOfContents.first { item in
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
}?.title
}
private func normalizedNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
guard let controller else { return nil }
if let currentLocation = controller.currentVisibleLocation(),
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
return controller.currentTableOfContentsItem?.title
}
return controller.flattenedTableOfContents.last { item in
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
}?.title
}
private func persistBookmarksAndRefreshChrome() {
guard let controller else { return }
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
controller.updateReaderChrome()
}
func currentBookmark() -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
return bookmark(matching: location)
}
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location else { return nil }
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
}
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
guard let controller else { return false }
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
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
}
if let bookmarkFragment = bookmark.location.fragment,
let locationFragment = location.fragment {
return bookmarkFragment == locationFragment
}
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
return progressionDelta <= threshold
}
private func bookmarkHref(for location: RDEPUBLocation) -> String {
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
}
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
let rawHref = href.components(separatedBy: "#").first ?? href
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
}
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
guard let controller else { return nil }
guard let location else { return nil }
guard let publication = controller.publication else {
return RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
private func normalizedBookmarkNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,81 @@
import UIKit
final class RDEPUBReaderAssemblyCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
func assembleInterface() {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
setupReaderView(readerView, in: controller.view)
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
}
func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime,
let controller = context.controller,
context.isExternalTextBook else {
return
}
let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
if let id = context.currentBookIdentifier {
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
}
if let textBook = controller.textBook {
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
} else {
runtime.finishPagination(restoreLocation: restoreLocation)
}
}
private func setupReaderView(_ readerView: RDEpubReaderView, in containerView: UIView) {
readerView.pageProvider = context.controller
readerView.delegate = context.controller
readerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(readerView)
NSLayoutConstraint.activate([
readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
readerView.register(
contentView: RDEPUBTrialWallContainerView.self,
contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTrialWallContainerView.self)
)
context.controller?.applyReaderViewConfiguration()
}
private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
loadingIndicator.hidesWhenStopped = true
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(loadingIndicator)
NSLayoutConstraint.activate([
loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
}
private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
errorLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(errorLabel)
NSLayoutConstraint.activate([
errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
}
}
@@ -0,0 +1,235 @@
import UIKit
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
context.runtime?.settingsPanelDidDisappear()
}
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
let toolView: RDEPUBReaderTopToolViewProviding =
context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
self?.handleBackAction()
}
toolView.onSearch = { [weak self] in
self?.toggleSearchBar()
}
toolView.onToggleBookmark = { [weak self] in
_ = self?.context.runtime?.toggleBookmark()
}
return toolView
}
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
let toolView: RDEPUBReaderBottomToolViewProviding =
context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView()
toolView.onShowTableOfContents = { [weak self] in
self?.presentTableOfContents()
}
toolView.onShowBookmarks = { [weak self] in
self?.context.runtime?.presentBookmarksManager()
}
toolView.onShowHighlights = { [weak self] in
self?.context.runtime?.presentHighlightsManager()
}
toolView.onAddHighlight = { [weak self] in
self?.context.runtime?.presentAnnotationCreation()
}
toolView.onShowSettings = { [weak self] in
self?.presentSettings()
}
return toolView
}
func updateReaderChrome() {
guard let controller else { return }
let uiState = makeUIState()
applyUIState(uiState)
updateSearchBar()
}
func makeUIState() -> RDEPUBReaderUIState {
guard let controller else { return .empty }
return RDEPUBReaderUIState(
canToggleBookmark: controller.currentBookIdentifier != nil,
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
canShowBookmarks: !controller.activeBookmarks.isEmpty,
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
showsSettingsPanel: controller.configuration.showsSettingsPanel
)
}
func applyUIState(_ state: RDEPUBReaderUIState) {
guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme)
controller.topToolView.setTitle(
controller.title
?? controller.parser?.metadata.title
?? controller.epubURL.deletingPathExtension().lastPathComponent
)
controller.topToolView.setBookmarkEnabled(state.canToggleBookmark)
controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation)
controller.bottomToolView.apply(theme: controller.configuration.theme)
controller.bottomToolView.updateVisibility(
showsTableOfContents: state.showsTableOfContents,
allowsHighlights: state.allowsHighlights,
showsSettingsPanel: state.showsSettingsPanel
)
controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks)
controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight)
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 }
context.runtime?.settingsPanelWillAppear()
let settingsController = RDEPUBReaderSettingsViewController(
configuration: controller.configuration,
brightness: controller.currentBrightness
)
settingsController.onBrightnessChange = { [weak controller] brightness in
controller?.setScreenBrightness(brightness)
}
settingsController.onFontSizeChange = { [weak controller] fontSize in
controller?.updateConfiguration { $0.fontSize = fontSize }
}
settingsController.onFontChoiceChange = { [weak controller] fontChoice in
controller?.updateConfiguration { $0.fontChoice = fontChoice }
}
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak controller] displayType in
controller?.updateConfiguration { $0.displayType = displayType }
}
settingsController.onThemeChange = { [weak controller] theme in
controller?.updateConfiguration { $0.theme = theme }
}
settingsController.onDismiss = { [weak self] in
self?.context.runtime?.settingsPanelDidDisappear()
}
let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet
navigationController.presentationController?.delegate = self
controller.present(navigationController, animated: true)
}
func presentTableOfContents() {
guard let controller else { return }
guard controller.configuration.showsTableOfContents else { return }
let items = controller.flattenedTableOfContentsItems(
from: controller.publication?.tableOfContents ?? [],
includePageNumbers: false
)
guard !items.isEmpty else { return }
let chapterController = RDEPUBReaderChapterListController(
items: items,
currentItem: controller.currentTableOfContentsItem,
theme: controller.configuration.theme
)
chapterController.onSelectItem = { [weak controller] item in
guard let controller else { return }
chapterController.dismiss(animated: true) {
_ = controller.go(toTableOfContentsItem: item, animated: true)
}
}
let navigationController = UINavigationController(rootViewController: chapterController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func toggleSearchBar() {
guard let controller else { return }
if controller.isSearchBarVisible {
controller.hideSearchBar()
} else {
controller.showSearchBar()
}
}
func updateSearchBar() {
guard let controller else { return }
controller.searchBarView.apply(theme: controller.configuration.theme)
if let searchState = controller.searchState {
if let index = searchState.currentMatchIndex {
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
controller.searchBarView.showNoResults()
}
}
}
func handleBackAction() {
guard let controller else { return }
close(controller)
}
private func close(_ controller: UIViewController) {
let target = closestDismissTarget(from: controller)
if let navigationController = target.navigationController,
navigationController.viewControllers.first !== target {
navigationController.popViewController(animated: true)
return
}
if let navigationController = target.navigationController,
navigationController.presentingViewController != nil {
navigationController.dismiss(animated: true)
return
}
if target.presentingViewController != nil {
target.dismiss(animated: true)
return
}
controller.dismiss(animated: true)
}
private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
var candidate: UIViewController = controller
var current = controller.parent
while let parent = current {
if let navigationController = parent.navigationController,
navigationController.viewControllers.contains(parent) {
return parent
}
if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
return parent
}
candidate = parent
current = parent.parent
}
return candidate
}
}
@@ -0,0 +1,420 @@
import UIKit
/// Captures layout parameters on the main thread for safe use on background queues.
/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread.
struct RDEPUBLayoutSnapshot {
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let renderSignature: String
}
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
weak var controller: RDEPUBReaderController?
weak var readerView: RDEpubReaderView?
let state: RDEPUBReaderState
let environment: RDEPUBReaderEnvironment
let services: RDEPUBReaderServices
var dependencies: RDEPUBReaderDependencies {
get { services.dependencies }
set {
services.dependencies = newValue
environment.displayEnvironment = newValue.environment
}
}
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
var parser: RDEPUBParser? {
get { state.parser }
set { state.parser = newValue }
}
var publication: RDEPUBPublication? {
get { state.publication }
set { state.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { state.readingSession }
set { state.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { state.textBook }
set { state.textBook = newValue }
}
var bookPageMap: RDEPUBBookPageMap? {
get { state.bookPageMap }
set { state.bookPageMap = newValue }
}
var activeBookmarks: [RDEPUBBookmark] {
get { state.activeBookmarks }
set { state.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { state.activeHighlights }
set { state.activeHighlights = newValue }
}
var currentBookIdentifier: String? {
get { state.currentBookIdentifier }
set { state.currentBookIdentifier = newValue }
}
var paginationToken: UUID {
get { state.paginationToken }
set { state.paginationToken = newValue }
}
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
}
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
get { state.pendingPageMapUpdates }
set { state.pendingPageMapUpdates = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { state.lastTextPaginationPageSize }
set { state.lastTextPaginationPageSize = newValue }
}
var lastMetadataParseWallClockMs: Int {
get { state.lastMetadataParseWallClockMs }
set { state.lastMetadataParseWallClockMs = newValue }
}
var lastMetadataParseConcurrency: Int {
get { state.lastMetadataParseConcurrency }
set { state.lastMetadataParseConcurrency = newValue }
}
var currentSelection: RDEPUBSelection? {
get { state.currentSelection }
set { state.currentSelection = newValue }
}
var selectionState: RDEPUBSelectionState {
get { state.selectionState }
set { state.selectionState = newValue }
}
var configuration: RDEPUBReaderConfiguration = .default
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool {
get { state.isRepaginating }
set { state.isRepaginating = newValue }
}
var didStartInitialLoad: Bool {
get { state.didStartInitialLoad }
set { state.didStartInitialLoad = newValue }
}
var isExternalTextBook: Bool {
get { state.isExternalTextBook }
set { state.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { state.textFileURL }
set { state.textFileURL = newValue }
}
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
let state = RDEPUBReaderState()
self.state = state
self.environment = RDEPUBReaderEnvironment(
controller: controller,
readerView: controller.readerView,
displayEnvironment: RDEPUBUIScreenEnvironment()
)
self.services = RDEPUBReaderServices(dependencies: .live)
}
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
environment.currentLayoutContext(configuration: configuration)
}
func currentPreferences() -> RDEPUBPreferences {
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return configuration.makePreferences(safeAreaInsets: safeInsets)
}
/// Captures all layout parameters needed for background chapter loading.
/// Must be called on the main thread. The returned snapshot is safe to use on any thread.
func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot {
dispatchPrecondition(condition: .onQueue(.main))
let pageSize = currentTextPageSize()
let style = currentTextRenderStyle()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
return RDEPUBLayoutSnapshot(
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
renderSignature: renderSignature
)
}
func currentTextPageSize() -> CGSize {
// M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too.
dispatchPrecondition(condition: .onQueue(.main))
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
if let readerView, let pageNum {
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize
}
}
let viewportSize = currentLayoutContext().viewportSize
if viewportSize.width > 0, viewportSize.height > 0 {
return viewportSize
}
if let lastTextPaginationPageSize,
lastTextPaginationPageSize.width > 0,
lastTextPaginationPageSize.height > 0 {
return lastTextPaginationPageSize
}
return environment.fallbackViewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextRenderStyle(configuration: configuration)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
services.resolvedTextRenderer(configuration: configuration)
}
var activePages: [EPUBPage] {
state.activePages
}
var activeChapters: [EPUBChapterInfo] {
state.activeChapters
}
var currentBrightness: CGFloat {
get { environment.currentBrightness }
set { environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
state.replaceActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
state.clearActiveSnapshot()
}
func makeParser() -> RDEPUBParser {
services.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
services.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
services.makeTextBookBuilder(
configuration: configuration,
cache: textBookCache,
layoutConfig: layoutConfig
)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
let contentHash: String
if let parser,
let publication,
publication.spine.indices.contains(spineIndex) {
let href = publication.spine[spineIndex].href
contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
} else {
contentHash = ""
}
return chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedContentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
renderSignature: String
) -> RDEPUBChapterCacheKey {
RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: precomputedContentHash
)
}
func currentRenderSignature() -> String {
dispatchPrecondition(condition: .onQueue(.main))
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
}
private func renderSignature(
style: RDEPUBTextRenderStyle,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig
) -> String {
[
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
}
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
}
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
return nil
}
return publication.spine.firstIndex {
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
}
}
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder {
services.makePlainTextBookBuilder(
configuration: configuration,
layoutConfig: 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
activityLock.unlock()
guard timestamp > 0 else { return .greatestFiniteMagnitude }
return CFAbsoluteTimeGetCurrent() - timestamp
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook else { return nil }
// External text books (e.g. plain .txt) have no publication; their
// chapter hrefs are matched verbatim.
let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href
return textBook.chapters.lazy
.first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.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()
}
}
@@ -0,0 +1,97 @@
import UIKit
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
var currentBrightness: CGFloat { get set }
var fallbackViewportSize: CGSize { get }
}
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
public init() {}
public var currentBrightness: CGFloat {
get { CGFloat(UIScreen.main.brightness) }
set { UIScreen.main.brightness = newValue }
}
public var fallbackViewportSize: CGSize {
UIScreen.main.bounds.size
}
}
public struct RDEPUBReaderDependencies {
public var environment: any RDEPUBReaderDisplayEnvironment
public var makeParser: () -> RDEPUBParser
public var makePaginator: () -> RDEPUBPaginator
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
/// nil 使 `RDEPUBReaderTopToolView`
public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)?
/// nil 使 `RDEPUBReaderBottomToolView`
public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)?
public init(
environment: any RDEPUBReaderDisplayEnvironment,
makeParser: @escaping () -> RDEPUBParser,
makePaginator: @escaping () -> RDEPUBPaginator,
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder,
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer,
makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil,
makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil
) {
self.environment = environment
self.makeParser = makeParser
self.makePaginator = makePaginator
self.makeTextBookBuilder = makeTextBookBuilder
self.makePlainTextBookBuilder = makePlainTextBookBuilder
self.makeTextRenderer = makeTextRenderer
self.makeTopToolView = makeTopToolView
self.makeBottomToolView = makeBottomToolView
}
/// provider
/// 宿 `RDEPUBResourceDataProvider` EPUB
/// - Parameter resourceDataProvider: nil `.live`
public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies {
var dependencies = RDEPUBReaderDependencies.live
guard let resourceDataProvider else { return dependencies }
dependencies.makeParser = {
let parser = RDEPUBParser()
parser.resourceDataProvider = resourceDataProvider
return parser
}
return dependencies
}
public static var live: RDEPUBReaderDependencies {
RDEPUBReaderDependencies(
environment: RDEPUBUIScreenEnvironment(),
makeParser: { RDEPUBParser() },
makePaginator: { RDEPUBPaginator() },
makeTextBookBuilder: { renderer, cache, layoutConfig in
RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
},
makePlainTextBookBuilder: { renderer, layoutConfig in
RDEpubPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
},
makeTextRenderer: { engine in
switch engine {
case .dtCoreText:
return RDEPUBDTCoreTextRenderer()
}
}
)
}
}
@@ -0,0 +1,98 @@
import UIKit
enum RDEPUBTextPageLayoutMetrics {
static let pageNumberTrailingPadding: CGFloat = 4
static let pageNumberFooterPadding: CGFloat = 8
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
static func contentInsets(
configuration: RDEPUBReaderConfiguration,
safeAreaInsets: UIEdgeInsets
) -> UIEdgeInsets {
let configInsets = configuration.reflowableContentInsets
return UIEdgeInsets(
top: max(configInsets.top, safeAreaInsets.top),
left: max(configInsets.left, safeAreaInsets.left),
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
right: max(configInsets.right, safeAreaInsets.right)
)
}
}
final class RDEPUBReaderEnvironment {
weak var controller: RDEPUBReaderController?
weak var readerView: RDEpubReaderView?
var displayEnvironment: any RDEPUBReaderDisplayEnvironment
init(
controller: RDEPUBReaderController,
readerView: RDEpubReaderView,
displayEnvironment: any RDEPUBReaderDisplayEnvironment
) {
self.controller = controller
self.readerView = readerView
self.displayEnvironment = displayEnvironment
}
func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
let resolvedSize = containerSize == .zero ? viewSize : containerSize
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
}
func currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
}
func currentTextLayoutConfig(
configuration: RDEPUBReaderConfiguration,
pageSize: CGSize
) -> RDEPUBTextLayoutConfig {
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
),
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
fallbackViewportSize: displayEnvironment.fallbackViewportSize
)
}
var currentBrightness: CGFloat {
get { displayEnvironment.currentBrightness }
set { displayEnvironment.currentBrightness = newValue }
}
var fallbackViewportSize: CGSize {
displayEnvironment.fallbackViewportSize
}
}
@@ -0,0 +1,86 @@
import Foundation
final class RDEPUBReaderLoadCoordinator {
private weak var context: RDEPUBReaderContext?
init(context: RDEPUBReaderContext) {
self.context = context
}
func startInitialLoadIfNeeded() {
guard let context, let controller = context.controller,
let readerView = context.readerView,
!controller.didStartInitialLoad,
readerView.bounds.width > 0,
readerView.bounds.height > 0 else {
return
}
controller.didStartInitialLoad = true
loadPublication()
}
func loadPublication() {
guard let context, let controller = context.controller else { return }
context.showLoading()
let loadToken = UUID()
context.paginationToken = loadToken
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self, let context = self.context, let controller = context.controller else { return }
let parser = context.makeParser()
do {
try parser.parse(epubURL: controller.epubURL)
let publication = parser.makePublication()
let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
DispatchQueue.main.async { [weak self] in
guard let self, let context = self.context else { return }
guard context.paginationToken == loadToken else { return }
context.runtime?.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
} catch {
DispatchQueue.main.async { [weak self] in
guard let self, let context = self.context else { return }
guard context.paginationToken == loadToken else { return }
context.handle(error: error)
}
}
}
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
guard let context, let controller = context.controller else { return }
context.parser = parser
context.publication = publication
context.currentBookIdentifier = bookIdentifier
context.activeBookmarks = bookmarks
context.activeHighlights = highlights
context.readingSession = RDEPUBReadingSession(publication: publication)
controller.title = parser.metadata.title.isEmpty
? controller.epubURL.deletingPathExtension().lastPathComponent
: parser.metadata.title
controller.applyReaderViewConfiguration()
context.updateReaderChrome()
controller.delegate?.epubReader(controller, didOpen: publication)
controller.applyOrientationLockIfNeeded()
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
}
}
@@ -0,0 +1,125 @@
import Foundation
final class RDEPUBReaderLocationCoordinator {
private unowned let context: RDEPUBReaderContext
private var lastPageChangeSpineIndex: Int?
init(context: RDEPUBReaderContext) {
self.context = context
}
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
guard let controller = context.controller,
let readerView = context.readerView else { return false }
if context.bookPageMap != nil {
_ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
}
guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
readerView.transitionToPage(pageNum: 0)
return false
}
if context.bookPageMap != nil {
guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
return false
}
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else if context.textBook == nil {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else {
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
recordPageChangeIfNeeded()
return true
}
func currentVisibleLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let readerView = context.readerView else {
return nil
}
let pageNumber = readerView.currentPage + 1
if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
return location
}
if let readingSession = context.readingSession,
readingSession.activePages.indices.contains(readerView.currentPage) {
return readingSession.fallbackLocation(
for: readingSession.activePages[readerView.currentPage],
bookIdentifier: context.currentBookIdentifier
)
}
}
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
}
func persistenceLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else {
return nil
}
return controller.persistence?.loadLocation(for: currentBookIdentifier)
}
func persist(location: RDEPUBLocation) {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else { return }
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateLocation: location)
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
controller.updateReaderChrome()
}
func recordPageChangeIfNeeded() {
guard let runtime = context.runtime,
let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return }
let currentPageNumber = readerView.currentPage + 1
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
return
}
if let lastSpineIndex = lastPageChangeSpineIndex,
lastSpineIndex != currentSpineIndex {
runtime.jumpSessionManager.recordPageChange(
fromSpineIndex: lastSpineIndex,
toSpineIndex: currentSpineIndex
)
}
lastPageChangeSpineIndex = currentSpineIndex
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
currentSpineIndex: currentSpineIndex,
isIdle: isIdle
) {
runtime.jumpSessionManager.endSession(endReason)
}
}
func resetPageChangeState() {
lastPageChangeSpineIndex = nil
}
}
@@ -0,0 +1,392 @@
import Foundation
final class RDEPUBReaderPaginationCoordinator {
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
let publication = context.publication,
let readingSession = context.readingSession else {
return
}
controller.isRepaginating = true
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
context.paginationToken = token
if publication.readingProfile == .textReflowable {
paginateTextPublication(
parser: parser,
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation,
token: token
)
return
}
if publication.layout == .fixed {
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
return
}
let paginator = context.makePaginator()
paginator.calculate(
parser: parser,
hostingView: controller.ensurePaginationHostView(),
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
) { [weak controller] pageCounts in
guard let controller, self.context.paginationToken == token else { return }
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: pageCounts,
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
context.textBook = textBook
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
guard !textBook.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
} else {
readerView.transitionToPage(pageNum: 0)
}
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
func repaginatePreservingCurrentLocation(
preferredRestoreLocation: RDEPUBLocation? = nil
) {
guard context.publication != nil else { return }
let restoreLocation = preferredRestoreLocation
?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
?? context.currentVisibleLocation()
?? context.persistenceLocation()
// Chapter pages, page counts, partial maps, and background coverage all
// depend on the viewport. Keeping them across a rotation can pair the
// new page map with chapters paginated for the old size, leaving an
// on-demand page in its loading state forever.
context.runtime?.prepareForFullRepagination()
paginatePublication(restoreLocation: restoreLocation)
}
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
if readerView.isPageCurlTransitioning {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.refreshVisibleContentPreservingLocation()
}
return
}
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
} else {
readerView.reloadData()
}
if let restoreLocation {
_ = context.restoreReadingLocation(restoreLocation)
}
}
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
let pageSize = controller.currentTextPageSize()
let style = controller.currentTextRenderStyle()
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
}
}
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?,
token: UUID
) {
guard let controller = context.controller else { return }
let context = self.context
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutSnapshot = context.makeLayoutSnapshot()
let runtime = context.runtime
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
guard let runtime else { return }
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
guard prioritizedCandidates.first != nil else {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: RDEPUBParserError.emptySpine)
}
return
}
do {
let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: prioritizedCandidates,
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"Pagination",
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
anchorChapter: runtimeChapter,
publication: publication,
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
RDEPUBBackgroundTrace.log(
"Pagination",
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
runtime.chapterRuntimeStore.setCurrentChapter(
spineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: initialChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
runtime.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
let cancellationController = self.beginMetadataParseCancellationController(for: token)
let worker = RDEPUBMetadataParseWorker(
context: context,
cancellationController: cancellationController,
token: token,
parser: parser,
publication: publication
)
RDEPUBBackgroundTrace.log(
"Pagination",
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
)
worker.start(token: token, restoreLocation: restoreLocation)
}
} catch {
RDEPUBBackgroundTrace.log(
"Pagination",
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: error)
}
}
}
}
private func loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: [Int],
runtime: RDEPUBReaderRuntime,
layoutSnapshot: RDEPUBLayoutSnapshot
) throws -> RDEPUBRuntimeChapter {
var lastError: Error?
for spineIndex in prioritizedSpineIndices {
do {
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore,
layoutSnapshot: layoutSnapshot
)
} catch {
lastError = error
}
}
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialInteractiveRuntimeChapters(
anchorChapter: RDEPUBRuntimeChapter,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime,
layoutSnapshot: RDEPUBLayoutSnapshot
) -> [RDEPUBRuntimeChapter] {
let minimumInteractivePageCount = 2
let maximumAdditionalChapters = 1
guard anchorChapter.pages.count < minimumInteractivePageCount else {
return [anchorChapter]
}
let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else {
return [anchorChapter]
}
var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter]
for offset in 1...maximumAdditionalChapters {
let candidatePositions = [anchorPosition + offset, anchorPosition - offset]
for candidatePosition in candidatePositions {
guard buildableSpineIndices.indices.contains(candidatePosition) else { continue }
let spineIndex = buildableSpineIndices[candidatePosition]
guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue }
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore,
layoutSnapshot: layoutSnapshot
)
selectedChapters.append(chapter)
} catch {
#if DEBUG
print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
#endif
}
}
let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count }
if loadedPageCount >= minimumInteractivePageCount {
break
}
}
return selectedChapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
func cancelActiveMetadataParseWork() {
metadataParseControlLock.lock()
let controller = activeMetadataParseCancellationController
activeMetadataParseCancellationController = nil
metadataParseControlLock.unlock()
controller?.cancel()
}
func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
metadataParseControlLock.lock()
if activeMetadataParseCancellationController === controller {
activeMetadataParseCancellationController = nil
}
metadataParseControlLock.unlock()
}
private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController {
let controller = RDEPUBMetadataParseCancellationController(token: token)
metadataParseControlLock.lock()
let previous = activeMetadataParseCancellationController
activeMetadataParseCancellationController = controller
metadataParseControlLock.unlock()
previous?.cancel()
return controller
}
}
@@ -0,0 +1,703 @@
import UIKit
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)
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
self?.handleDeferredCFIMapReady(for: spineIndex)
}
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)
lazy var presentationRuntime = RDEPUBPresentationRuntime(
context: context,
locationCoordinator: locationCoordinator,
jumpSessionManager: jumpSessionManager,
reconciliationCoordinator: reconciliationCoordinator
)
lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
context: context,
store: chapterRuntimeStore,
loader: chapterLoader,
presentationRuntime: presentationRuntime,
locationCoordinator: locationCoordinator,
backgroundPriorityManager: backgroundPriorityManager,
jumpSessionManager: jumpSessionManager,
refreshVisibleContentPreservingLocation: { [weak self] in
self?.refreshVisibleContentPreservingLocation()
}
)
var isSettingsPanelOpen: Bool = false
var needsFullRepaginationAfterSettingsClose: Bool = false
private var settingsPreviewGeneration: Int = 0
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
/// The location before the settings preview replaces the full book page map
/// with the current chapter's temporary map. The temporary map rebases that
/// chapter at page zero, so it must never be used as the final restore source.
private var settingsRestoreLocation: RDEPUBLocation?
/// A settings session must keep one immutable text offset. Re-capturing the
/// start of each preview page makes repeated font-size changes drift backward.
private var settingsPreviewAnchor: SettingsPreviewAnchor?
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
private struct SettingsPreviewAnchor {
let spineIndex: Int
let href: String
let offset: Int
}
init(context: RDEPUBReaderContext) {
self.context = context
}
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
chromeCoordinator.makeTopToolView()
}
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
chromeCoordinator.makeBottomToolView()
}
func startInitialLoadIfNeeded() {
loadCoordinator.startInitialLoadIfNeeded()
}
func reloadBook() {
guard let readerView = context.readerView else { return }
context.didStartInitialLoad = false
context.parser = nil
context.publication = nil
context.clearActiveSnapshot()
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
clearOnDemandPageModeState()
viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData()
startInitialLoadIfNeeded()
}
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard context.controller != nil,
let readerView = context.readerView,
pageNumber > 0 else {
return false
}
if let textBook = context.textBook {
guard textBook.page(at: pageNumber) != nil else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
if context.bookPageMap != nil {
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
guard context.activePages.indices.contains(pageNumber - 1) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
annotationCoordinator.bookmark(withID: id)
}
func highlight(withID id: String) -> RDEPUBHighlight? {
annotationCoordinator.highlight(withID: id)
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
annotationCoordinator.upsertHighlight(highlight)
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
annotationCoordinator.removeHighlight(id: id)
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
annotationCoordinator.updateHighlightNote(id: id, note: note)
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toHighlightID: id, animated: animated)
}
func removeAllHighlights() {
annotationCoordinator.removeAllHighlights()
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.addBookmark(note: note)
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.toggleBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
annotationCoordinator.removeBookmark(id: id)
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toBookmarkID: id, animated: animated)
}
func presentBookmarksManager() {
annotationCoordinator.presentBookmarksManager()
}
func presentHighlightsManager() {
annotationCoordinator.presentHighlightsManager()
}
func presentAnnotationCreation() {
annotationCoordinator.presentAnnotationCreation()
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
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()
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
searchCoordinator.searchPresentation(for: page)
}
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
func presentSettings() {
chromeCoordinator.presentSettings()
}
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
func loadPublication() {
loadCoordinator.loadPublication()
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
loadCoordinator.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
presentationRuntime.applyBookPageMap(
bookPageMap,
restoreLocation: restoreLocation
) { [weak self] restoreLocation in
self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
}
func applyPendingFullPageMapIfNeeded() {
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
func repaginatePreservingCurrentLocation() {
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
scheduleSettingsPreviewRepagination()
} else {
paginationCoordinator.repaginatePreservingCurrentLocation()
}
}
private func scheduleSettingsPreviewRepagination() {
pendingSettingsPreviewWorkItem?.cancel()
settingsPreviewGeneration += 1
let previewGeneration = settingsPreviewGeneration
let workItem = DispatchWorkItem { [weak self] in
guard let self,
self.isSettingsPanelOpen,
previewGeneration == self.settingsPreviewGeneration else {
return
}
self.pendingSettingsPreviewWorkItem = nil
let previewAnchor = self.settingsPreviewAnchor
?? self.captureSettingsPreviewAnchor()
self.chapterRuntimeStore.invalidateAllLayoutDependentContent()
self.repaginateCurrentChapterOnly(
previewGeneration: previewGeneration,
previewAnchor: previewAnchor
)
}
pendingSettingsPreviewWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + settingsPreviewDebounceDelay,
execute: workItem
)
}
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 }
let currentPageNumber = readerView.currentPage + 1
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
return
}
let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation()
chapterLoader.loadChapter(
spineIndex: currentSpineIndex,
store: chapterRuntimeStore,
priority: .preview
) { [weak self] result in
guard let self,
self.isSettingsPanelOpen,
previewGeneration == self.settingsPreviewGeneration,
let readerView = self.context.readerView else {
return
}
switch result {
case .success(let chapter):
let partialMap = self.makePartialPageMap(from: [chapter])
self.presentationRuntime.applySettingsPreviewPageMap(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):
break
}
}
}
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
isSettingsPanelOpen = true
needsFullRepaginationAfterSettingsClose = false
let fallbackLocation = locationCoordinator.currentVisibleLocation()
?? context.persistenceLocation()
settingsPreviewAnchor = captureSettingsPreviewAnchor()
settingsRestoreLocation = exactSettingsRestoreLocation(
anchor: settingsPreviewAnchor,
fallbackLocation: fallbackLocation
)
settingsPreviewGeneration += 1
}
func settingsPanelDidDisappear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
isSettingsPanelOpen = false
settingsPreviewGeneration += 1
if needsFullRepaginationAfterSettingsClose {
needsFullRepaginationAfterSettingsClose = false
paginationCoordinator.repaginatePreservingCurrentLocation(
preferredRestoreLocation: settingsRestoreLocation
)
}
settingsRestoreLocation = nil
settingsPreviewAnchor = nil
}
private func exactSettingsRestoreLocation(
anchor: SettingsPreviewAnchor?,
fallbackLocation: RDEPUBLocation?
) -> RDEPUBLocation? {
guard let anchor else { return fallbackLocation }
let textAnchor = RDEPUBTextAnchor(
fileIndex: anchor.spineIndex,
row: 0,
column: 0,
chapterOffset: anchor.offset,
fragmentID: fallbackLocation?.fragment
)
return RDEPUBLocation(
bookIdentifier: fallbackLocation?.bookIdentifier ?? context.currentBookIdentifier,
href: anchor.href,
progression: fallbackLocation?.progression ?? 0,
lastProgression: fallbackLocation?.lastProgression,
fragment: fallbackLocation?.fragment,
rangeAnchor: RDEPUBTextRangeAnchor(start: textAnchor, end: textAnchor)
)
}
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
locationCoordinator.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
@discardableResult
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
}
@discardableResult
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
chapterWarmupOrchestrator.prepareOnDemandChapter(
forAbsolutePageNumber: pageNumber,
allowSynchronousLoad: allowSynchronousLoad,
completion: completion
)
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
currentPageNumber: currentPageNumber,
minimumTrailingPages: minimumTrailingPages,
batchChapterCount: batchChapterCount
)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount
)
}
func clearOnDemandPageModeState() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllLayoutDependentContent()
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
}
/// Clears every runtime value derived from the current viewport before a
/// full repagination. The caller must capture the visible location first,
/// because chapter/page resolution is intentionally invalid after this.
func prepareForFullRepagination() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllLayoutDependentContent()
context.pendingPageMapUpdates.removeAll()
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
context.controller?.textDisplayCache.removeAll()
}
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let activeWindowIndices: Set<Int> = if let currentSpineIndex {
[currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
} else {
[]
}
let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
backgroundCoverageStore.handleMemoryWarning(
activeWindowSpineIndices: activeWindowIndices,
protectedSpineIndices: protectedIndices
)
chapterRuntimeStore.handleMemoryWarning()
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func handleDeferredCFIMapReady(for spineIndex: Int) {
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
visibleSpineIndex == spineIndex else {
return
}
refreshVisibleContentPreservingLocation()
}
}
@@ -0,0 +1,414 @@
import Foundation
final class RDEPUBReaderSearchCoordinator {
private unowned let context: RDEPUBReaderContext
private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
private let tokenLock = NSLock()
private var _currentSearchToken: UUID = UUID()
private var currentSearchToken: UUID {
get {
tokenLock.lock()
defer { tokenLock.unlock() }
return _currentSearchToken
}
set {
tokenLock.lock()
_currentSearchToken = newValue
tokenLock.unlock()
}
}
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func search(keyword: String) {
guard let controller else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
clearSearch()
return
}
let token = UUID()
currentSearchToken = token
let searchEngine = makeSearchEngine()
let layoutSnapshot = context.makeLayoutSnapshot()
searchQueue.async { [weak self] in
guard let self, self.currentSearchToken == token else { return }
let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
DispatchQueue.main.async { [weak self] in
guard let self, self.currentSearchToken == token else { return }
guard let controller = self.context.controller else { return }
controller.searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
self.notifySearchStateChanged()
if matches.isEmpty {
controller.refreshVisibleContentPreservingLocation()
} else {
_ = self.navigateToCurrentSearchMatch(animated: false)
}
}
}
}
@discardableResult
func searchNext() -> Bool {
advanceSearch(by: 1)
}
@discardableResult
func searchPrevious() -> Bool {
advanceSearch(by: -1)
}
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState,
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
func clearSearch() {
currentSearchToken = UUID()
guard let controller else { return }
controller.searchState = nil
notifySearchStateChanged()
controller.refreshVisibleContentPreservingLocation()
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
guard let controller else { return nil }
guard let searchState = controller.searchState,
let publication = controller.publication else {
return nil
}
let pageHrefs: [String]
if let fixedSpread = page.fixedSpread {
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
} else if publication.spine.indices.contains(page.spineIndex) {
pageHrefs = [
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
?? publication.spine[page.spineIndex].href
]
} else {
pageHrefs = []
}
guard !pageHrefs.isEmpty else {
return nil
}
let currentMatch = searchState.currentMatch
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
let resources = pageHrefs.map { href in
let matchCount = searchState.matches.filter {
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
}.count
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
return RDEPUBSearchPresentationResource(
href: href,
matchCount: matchCount,
activeLocalMatchIndex: activeLocalMatchIndex
)
}
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
}
// MARK: - Private
private func makeSearchEngine() -> SearchEngineSnapshot? {
guard let controller else { return nil }
if let textBook = controller.textBook, let publication = controller.publication {
return .textBook(textBook, publication)
}
// External text books (e.g. plain .txt) carry a textBook but no
// publication/parser/bookPageMap; search over the chapter text
// directly using hrefs from the textBook itself.
if let textBook = controller.textBook {
return .externalTextBook(textBook)
}
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
return .onDemand(publication)
}
if let parser = controller.parser, let publication = controller.publication {
return .html(parser, publication)
}
return nil
}
private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
guard let engine else { return [] }
switch engine {
case .textBook(let textBook, let publication):
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
case .externalTextBook(let textBook):
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
case .onDemand(let publication):
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
case .html(let parser, let publication):
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
}
private enum SearchEngineSnapshot {
case textBook(RDEPUBTextBook, RDEPUBPublication)
case externalTextBook(RDEPUBTextBook)
case onDemand(RDEPUBPublication)
case html(RDEPUBParser, RDEPUBPublication)
}
// H-09: Each chapter iteration is wrapped in autoreleasepool to release
// the chapter's typesetAttributedString memory between iterations.
private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return []
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
layoutSnapshot: layoutSnapshot
) else {
return []
}
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
var localMatches: [RDEPUBSearchMatch] = []
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else {
break
}
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
localMatches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return localMatches
} // end autoreleasepool
matches.append(contentsOf: chapterMatches)
}
return matches
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func advanceSearch(by delta: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
return false
}
let currentIndex = searchState.currentMatchIndex ?? 0
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
searchState.currentMatchIndex = nextIndex
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
private func notifySearchStateChanged() {
guard let controller else { return }
let state = controller.searchState
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
}
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
guard let controller else { return false }
guard let searchMatch = controller.searchState?.currentMatch else {
controller.refreshVisibleContentPreservingLocation()
return false
}
if let targetPageNumber = pageNumber(for: searchMatch),
controller.readerView.currentPage == targetPageNumber - 1 {
controller.refreshVisibleContentPreservingLocation()
return true
}
let location = RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
return controller.restoreReadingLocation(location, animated: animated)
}
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let controller else { return nil }
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
if let exactPageNumber = exactPageNumber(
for: searchMatch,
in: chapterData,
keyword: controller.searchState?.keyword
) {
return exactPageNumber
}
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
if let rangeLocation = searchMatch.rangeLocation,
let page = chapterData.page(containing: rangeLocation) {
return page.absolutePageIndex + 1
}
}
let location = RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
if let textBook = controller.textBook {
if let publication = controller.publication {
return textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: controller.currentBookIdentifier
)
}
// External text books have no resolver; hrefs match verbatim.
return textBook.chapterData(for: location.href)?.pageNumber(for: location)
}
return controller.readingSession?.pageIndex(
for: location,
bookIdentifier: controller.currentBookIdentifier
).map { $0 + 1 }
}
private func exactPageNumber(
for searchMatch: RDEPUBSearchMatch,
in chapterData: RDEPUBChapterData,
keyword: String?
) -> Int? {
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedKeyword.isEmpty else { return nil }
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return nil }
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
if localMatchIndex == searchMatch.localMatchIndex,
let page = chapterData.page(containing: foundRange.location) {
return page.absolutePageIndex + 1
}
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return nil
}
}
@@ -0,0 +1,54 @@
import Foundation
final class RDEPUBReaderServices {
var dependencies: RDEPUBReaderDependencies
init(dependencies: RDEPUBReaderDependencies) {
self.dependencies = dependencies
}
func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
func makeTextBookBuilder(
configuration: RDEPUBReaderConfiguration,
cache: RDEPUBTextBookCache?,
layoutConfig: RDEPUBTextLayoutConfig
) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(
resolvedTextRenderer(configuration: configuration),
cache,
layoutConfig
)
}
func makePlainTextBookBuilder(
configuration: RDEPUBReaderConfiguration,
layoutConfig: RDEPUBTextLayoutConfig
) -> RDEpubPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(
resolvedTextRenderer(configuration: configuration),
layoutConfig
)
}
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
}
}
@@ -0,0 +1,75 @@
import UIKit
/// M-01: All properties must be accessed exclusively from the main thread.
/// This is currently enforced by convention all verified access paths are main-thread-only.
/// Adding @MainActor would formalize this but requires iOS 15+ and Swift concurrency throughout.
/// For now, rely on the audit-verified access patterns and consider @MainActor in a future refactor.
final class RDEPUBReaderState {
var parser: RDEPUBParser?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var bookPageMap: RDEPUBBookPageMap?
var activeBookmarks: [RDEPUBBookmark] = []
var activeHighlights: [RDEPUBHighlight] = []
var currentBookIdentifier: String?
var paginationToken = UUID()
var searchState: RDEPUBSearchState?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
var lastTextPaginationPageSize: CGSize?
var lastMetadataParseWallClockMs: Int = 0
var lastMetadataParseConcurrency: Int = 0
var selectionState: RDEPUBSelectionState = .idle
var isRepaginating: Bool = false
var didStartInitialLoad: Bool = false
var isExternalTextBook: Bool = false
var textFileURL: URL?
let textBookCache = RDEPUBTextBookCache()
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
}
@@ -0,0 +1,34 @@
import Foundation
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,
canShowBookmarks: false,
canAddHighlight: false,
canShowHighlights: false,
showsTableOfContents: true,
allowsHighlights: true,
showsSettingsPanel: true
)
}
@@ -0,0 +1,129 @@
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) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func viewDidLayoutSubviews() {
guard let controller else { return }
guard let viewportSignature = currentViewportSignature() else { return }
if !controller.didStartInitialLoad {
lastAppliedViewportSignature = viewportSignature
controller.startInitialLoadIfNeeded()
return
}
guard controller.publication != nil || controller.isExternalTextBook else {
lastAppliedViewportSignature = viewportSignature
return
}
guard !isWaitingForViewportTransitionCompletion else {
return
}
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
}
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
guard let controller else { return }
guard controller.didStartInitialLoad else { return }
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
isWaitingForViewportTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self, let controller = self.controller else { return }
self.isWaitingForViewportTransitionCompletion = false
controller.view.layoutIfNeeded()
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
}
}
func resetForReload() {
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
pendingPresentationRestoreLocation = nil
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
DispatchQueue.main.async { [weak self] in
self?.handleViewportChangeIfNeeded(reason: pendingReason)
}
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
guard let controller else { return nil }
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
let insets = controller.view.safeAreaInsets
return RDEPUBViewportSignature(
width: containerSize.width,
height: containerSize.height,
safeTop: insets.top,
safeLeft: insets.left,
safeBottom: insets.bottom,
safeRight: insets.right
)
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
guard let controller else { return }
guard controller.didStartInitialLoad,
let signature = viewportSignature ?? currentViewportSignature() else {
return
}
if controller.isRepaginating {
pendingViewportChangeReason = reason
return
}
if let lastAppliedViewportSignature,
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
return
}
lastAppliedViewportSignature = signature
if controller.isExternalTextBook {
controller.rebuildExternalTextBook()
return
}
guard controller.publication != nil else { return }
controller.repaginatePreservingCurrentLocation()
}
}
@@ -0,0 +1,31 @@
import Foundation
enum RDEPUBSelectionState: Equatable {
case idle
case selecting(anchor: Int)
case selected(RDEPUBSelection)
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
var hasSelection: Bool {
switch self {
case .idle:
return false
case .selecting, .selected, .committingAction:
return true
}
}
var selection: RDEPUBSelection? {
switch self {
case .idle, .selecting:
return nil
case .selected(let selection), .committingAction(let selection, _):
return selection
}
}
}
@@ -0,0 +1,215 @@
import UIKit
public enum RDEPUBTextRenderingEngine: Equatable {
case dtCoreText
}
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
case system
case serif
case rounded
case monospaced
public var displayName: String {
switch self {
case .system:
return "系统"
case .serif:
return "宋体"
case .rounded:
return "圆体"
case .monospaced:
return "等宽"
}
}
public func font(ofSize size: CGFloat) -> UIFont {
switch self {
case .system:
return UIFont.systemFont(ofSize: size)
case .serif:
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
.withDesign(.serif) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
return UIFont(descriptor: descriptor, size: size)
case .rounded:
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
.withDesign(.rounded) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
return UIFont(descriptor: descriptor, size: size)
case .monospaced:
return UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
}
}
/// UI 宿 delegate
public struct RDEPUBReaderTrialPolicy: Equatable {
/// spine 0..<readableChapterCount
/// nil = readoor epub
/// epub + bookPageMap
public var readableChapterCount: Int?
public init(readableChapterCount: Int? = nil) {
self.readableChapterCount = readableChapterCount
}
}
public struct RDEPUBReaderConfiguration: Equatable {
public var fontSize: CGFloat
public var lineHeightMultiple: CGFloat
public var fontChoice: RDEPUBReaderFontChoice
public var numberOfColumns: Int
public var columnGap: CGFloat
public var displayType: RDEpubReaderView.DisplayType
public var landscapeDualPageEnabled: Bool
public var showsTableOfContents: Bool
public var allowsHighlights: Bool
public var showsSettingsPanel: Bool
public var reflowableContentInsets: UIEdgeInsets
public var fixedContentInset: UIEdgeInsets
public var theme: RDEPUBReaderTheme
public var darkImageAdjustmentEnabled: Bool
public var darkImageBlendRatio: CGFloat
public var fixedLayoutFit: RDEPUBFixedLayoutFit
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
public var textRenderingEngine: RDEPUBTextRenderingEngine
public var onDemandChapterWindowSize: Int
public var metadataParsingConcurrency: Int
public var jumpSessionPolicy: RDEPUBJumpSessionPolicy
public var allowedExternalURLSchemes: Set<String>
public var requiresExternalLinkConfirmation: Bool
public var allowsInspectableWebViews: Bool
public var enablesVerboseWebViewLogging: Bool
/// nil =
public var trialPolicy: RDEPUBReaderTrialPolicy?
public init(
fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6,
fontChoice: RDEPUBReaderFontChoice = .system,
numberOfColumns: Int = 1,
columnGap: CGFloat = 20,
displayType: RDEpubReaderView.DisplayType = .pageCurl,
landscapeDualPageEnabled: Bool = true,
showsTableOfContents: Bool = true,
allowsHighlights: Bool = true,
showsSettingsPanel: Bool = true,
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
fixedContentInset: UIEdgeInsets = .zero,
theme: RDEPUBReaderTheme = .light,
darkImageAdjustmentEnabled: Bool = true,
darkImageBlendRatio: CGFloat = 0.15,
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default,
allowedExternalURLSchemes: Set<String> = ["https"],
requiresExternalLinkConfirmation: Bool = true,
allowsInspectableWebViews: Bool = false,
enablesVerboseWebViewLogging: Bool = false,
trialPolicy: RDEPUBReaderTrialPolicy? = nil
) {
self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple
self.fontChoice = fontChoice
self.numberOfColumns = max(1, numberOfColumns)
self.columnGap = max(0, columnGap)
self.displayType = displayType
self.landscapeDualPageEnabled = landscapeDualPageEnabled
self.showsTableOfContents = showsTableOfContents
self.allowsHighlights = allowsHighlights
self.showsSettingsPanel = showsSettingsPanel
self.reflowableContentInsets = reflowableContentInsets
self.fixedContentInset = fixedContentInset
self.theme = theme
self.darkImageAdjustmentEnabled = darkImageAdjustmentEnabled
self.darkImageBlendRatio = max(0, min(0.35, darkImageBlendRatio))
self.fixedLayoutFit = fixedLayoutFit
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
self.jumpSessionPolicy = jumpSessionPolicy
self.allowedExternalURLSchemes = allowedExternalURLSchemes
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
self.allowsInspectableWebViews = allowsInspectableWebViews
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
self.trialPolicy = trialPolicy
}
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
}
var chapterWindowRadius: Int {
onDemandChapterWindowSize / 2
}
}
extension RDEPUBReaderConfiguration {
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
// to prevent content from being hidden under Dynamic Island / home indicator.
// Falls back to the key-window safe area when the caller has no laid-out view.
let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
let safeInsets = UIEdgeInsets(
top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
)
return RDEPUBPreferences(
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple,
reflowableContentInsets: safeInsets,
fixedContentInset: fixedContentInset,
numberOfColumns: numberOfColumns,
columnGap: columnGap,
themeBackgroundColor: theme.themeBackgroundColorCSS,
themeTextColor: theme.themeTextColorCSS,
fixedBackgroundColor: theme.themeBackgroundColorCSS,
fixedLayoutFit: fixedLayoutFit,
fixedLayoutSpreadMode: fixedLayoutSpreadMode
)
}
}
@@ -0,0 +1,160 @@
import UIKit
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
case pageCurl
case horizontalScroll
case verticalScroll
case horizontalCoverScroll
init(displayType: RDEpubReaderView.DisplayType) {
switch displayType {
case .pageCurl:
self = .pageCurl
case .horizontalScroll:
self = .horizontalScroll
case .verticalScroll:
self = .verticalScroll
}
}
var displayType: RDEpubReaderView.DisplayType {
switch self {
case .pageCurl:
return .pageCurl
case .horizontalScroll, .horizontalCoverScroll:
return .horizontalScroll
case .verticalScroll:
return .verticalScroll
}
}
}
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:
return .light
case .yellow:
return .yellow
case .green:
return .green
case .pink:
return .pink
case .blue:
return .blue
case .dark:
return .dark
}
}
public init?(theme: RDEPUBReaderTheme) {
switch theme {
case .light:
self = .light
case .yellow:
self = .yellow
case .green:
self = .green
case .pink:
self = .pink
case .blue:
self = .blue
case .dark:
self = .dark
default:
return nil
}
}
}
public struct RDEPUBReaderSettings: Codable, Equatable {
public var brightness: CGFloat?
public var fontSize: CGFloat?
public var fontChoice: RDEPUBReaderFontChoice?
public var lineHeightMultiple: CGFloat?
public var numberOfColumns: Int?
public var displayMode: RDEPUBReaderDisplayMode?
public var themePreset: RDEPUBReaderThemePreset?
public init(
brightness: CGFloat? = nil,
fontSize: CGFloat? = nil,
fontChoice: RDEPUBReaderFontChoice? = nil,
lineHeightMultiple: CGFloat? = nil,
numberOfColumns: Int? = nil,
displayMode: RDEPUBReaderDisplayMode? = nil,
themePreset: RDEPUBReaderThemePreset? = nil
) {
self.brightness = brightness
self.fontSize = fontSize
self.fontChoice = fontChoice
self.lineHeightMultiple = lineHeightMultiple
self.numberOfColumns = numberOfColumns
self.displayMode = displayMode
self.themePreset = themePreset
}
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
var resolvedConfiguration = configuration
if let fontSize {
resolvedConfiguration.fontSize = fontSize
}
if let fontChoice {
resolvedConfiguration.fontChoice = fontChoice
}
if let lineHeightMultiple {
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
}
if let numberOfColumns {
resolvedConfiguration.numberOfColumns = max(1, numberOfColumns)
}
if let displayMode {
resolvedConfiguration.displayType = displayMode.displayType
}
if let themePreset {
resolvedConfiguration.theme = themePreset.theme
}
return resolvedConfiguration
}
public static func capture(
configuration: RDEPUBReaderConfiguration,
brightness: CGFloat
) -> RDEPUBReaderSettings {
RDEPUBReaderSettings(
brightness: max(0, min(1, brightness)),
fontSize: configuration.fontSize,
fontChoice: configuration.fontChoice,
lineHeightMultiple: configuration.lineHeightMultiple,
numberOfColumns: configuration.numberOfColumns,
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
)
}
}
@@ -0,0 +1,368 @@
import UIKit
final class RDEPUBReaderSettingsViewController: UIViewController {
var onBrightnessChange: ((CGFloat) -> Void)?
var onFontSizeChange: ((CGFloat) -> Void)?
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
var onLineHeightChange: ((CGFloat) -> Void)?
var onColumnCountChange: ((Int) -> Void)?
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
var onDismiss: (() -> Void)?
private enum ThemePreset: Int, CaseIterable {
case light
case yellow
case green
case pink
case blue
case dark
var title: String {
switch self {
case .light: return "浅色"
case .yellow: return "米黄"
case .green: return "青绿"
case .pink: return "粉色"
case .blue: return "蓝灰"
case .dark: return "夜间"
}
}
var theme: RDEPUBReaderTheme {
switch self {
case .light: return .light
case .yellow: return .yellow
case .green: return .green
case .pink: return .pink
case .blue: return .blue
case .dark: return .dark
}
}
}
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
stackView.distribution = .fillEqually
stackView.spacing = 12
return stackView
}()
private var themeButtons: [UIButton] = []
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var currentConfiguration: RDEPUBReaderConfiguration
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
self.currentConfiguration = configuration
super.init(nibName: nil, bundle: nil)
brightnessSlider.value = Float(brightness)
title = "样式设置"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationItems()
setupViews()
syncControls()
applyTheme(currentConfiguration.theme)
}
private func setupNavigationItems() {
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
navigationItem.rightBarButtonItem = doneItem
}
private func setupViews() {
view.addSubview(scrollView)
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
])
brightnessSlider.minimumValue = 0
brightnessSlider.maximumValue = 1
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
fontValueLabel.textAlignment = .center
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
configureFontButton(decreaseFontButton, title: "A-")
configureFontButton(increaseFontButton, title: "A+")
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
ThemePreset.allCases.forEach { preset in
let button = UIButton(type: .system)
button.tag = preset.rawValue
button.layer.cornerRadius = 18
button.layer.borderWidth = 1.5
button.backgroundColor = preset.theme.contentBackgroundColor
button.accessibilityLabel = preset.title
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
themeButtons.append(button)
themeStackView.addArrangedSubview(button)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 36)
])
}
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
}
private func makeSection(title: String, content: UIView) -> UIView {
let container = UIStackView()
container.axis = .vertical
container.spacing = 10
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
content.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(titleLabel)
container.addArrangedSubview(content)
return container
}
private func makeFontSizeRow() -> UIView {
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fill
stackView.spacing = 12
NSLayoutConstraint.activate([
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
])
return stackView
}
private func configureFontButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
button.layer.cornerRadius = 18
button.layer.borderWidth = 1
}
private func syncControls() {
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
lineHeightControl.selectedSegmentIndex = lineHeightIndex
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
switch currentConfiguration.displayType {
case .pageCurl:
displayTypeControl.selectedSegmentIndex = 0
case .horizontalScroll:
displayTypeControl.selectedSegmentIndex = 1
case .verticalScroll:
displayTypeControl.selectedSegmentIndex = 2
}
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
updateThemeSelection(selectedPreset)
updateControlAccessibilityValues()
}
private func applyTheme(_ theme: RDEPUBReaderTheme) {
view.backgroundColor = theme.contentBackgroundColor
scrollView.backgroundColor = theme.contentBackgroundColor
contentStack.arrangedSubviews
.compactMap { $0 as? UIStackView }
.flatMap { $0.arrangedSubviews }
.compactMap { $0 as? UILabel }
.forEach { $0.textColor = theme.contentTextColor }
fontValueLabel.textColor = theme.contentTextColor
[decreaseFontButton, increaseFontButton].forEach { button in
button.setTitleColor(theme.toolControlTextColor, for: .normal)
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
button.backgroundColor = theme.toolBackgroundColor
}
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
control.backgroundColor = theme.toolBackgroundColor
if #available(iOS 13.0, *) {
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
} else {
control.tintColor = theme.toolControlTextColor
}
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
}
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
}
private func updateThemeSelection(_ preset: ThemePreset) {
themeButtons.forEach { button in
let isSelected = button.tag == preset.rawValue
button.layer.borderWidth = isSelected ? 2 : 1
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
button.accessibilityValue = isSelected ? "selected" : "unselected"
}
}
private func updateControlAccessibilityValues() {
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
}
@objc private func doneAction() {
dismiss(animated: true) { [weak self] in
self?.onDismiss?()
}
}
@objc private func brightnessChanged(_ slider: UISlider) {
onBrightnessChange?(CGFloat(slider.value))
}
@objc private func decreaseFontAction() {
let nextValue = max(12, currentConfiguration.fontSize - 1)
guard nextValue != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = nextValue
fontValueLabel.text = String(Int(nextValue.rounded()))
onFontSizeChange?(nextValue)
}
@objc private func increaseFontAction() {
let nextValue = min(36, currentConfiguration.fontSize + 1)
guard nextValue != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = nextValue
fontValueLabel.text = String(Int(nextValue.rounded()))
onFontSizeChange?(nextValue)
}
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
let choices = RDEPUBReaderFontChoice.allCases
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
let choice = choices[index]
guard choice != currentConfiguration.fontChoice else { return }
currentConfiguration.fontChoice = choice
updateControlAccessibilityValues()
onFontChoiceChange?(choice)
}
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
let value = lineHeightValues[index]
currentConfiguration.lineHeightMultiple = value
updateControlAccessibilityValues()
onLineHeightChange?(value)
}
@objc private func columnCountChanged(_ control: UISegmentedControl) {
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
currentConfiguration.numberOfColumns = numberOfColumns
updateControlAccessibilityValues()
onColumnCountChange?(numberOfColumns)
}
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
let displayType: RDEpubReaderView.DisplayType
switch control.selectedSegmentIndex {
case 1:
displayType = .horizontalScroll
case 2:
displayType = .verticalScroll
default:
displayType = .pageCurl
}
currentConfiguration.displayType = displayType
updateControlAccessibilityValues()
onDisplayTypeChange?(displayType)
}
@objc private func themeButtonAction(_ sender: UIButton) {
guard let preset = ThemePreset(rawValue: sender.tag) else { return }
currentConfiguration.theme = preset.theme
applyTheme(preset.theme)
onThemeChange?(preset.theme)
}
}
@@ -0,0 +1,103 @@
import UIKit
public struct RDEPUBReaderTheme: Equatable {
public var contentBackgroundColor: UIColor
public var contentTextColor: UIColor
public var toolBackgroundColor: UIColor
public var toolControlTextColor: UIColor
public var toolControlBorderUnselectColor: UIColor
public var toolLineColor: UIColor
public init(
contentBackgroundColor: UIColor,
contentTextColor: UIColor,
toolBackgroundColor: UIColor,
toolControlTextColor: UIColor,
toolControlBorderUnselectColor: UIColor,
toolLineColor: UIColor
) {
self.contentBackgroundColor = contentBackgroundColor
self.contentTextColor = contentTextColor
self.toolBackgroundColor = toolBackgroundColor
self.toolControlTextColor = toolControlTextColor
self.toolControlBorderUnselectColor = toolControlBorderUnselectColor
self.toolLineColor = toolLineColor
}
public static let light = RDEPUBReaderTheme(
contentBackgroundColor: .white,
contentTextColor: .black,
toolBackgroundColor: .white,
toolControlTextColor: .black,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
public static let dark = RDEPUBReaderTheme(
contentBackgroundColor: .black,
contentTextColor: .white,
toolBackgroundColor: .black,
toolControlTextColor: .white,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
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,
toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
toolControlTextColor: .black,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
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,
toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
toolControlTextColor: .black,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
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,
toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
toolControlTextColor: .black,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
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,
toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
toolControlTextColor: .black,
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
}
extension RDEPUBReaderTheme {
/// Background color for the full-screen image viewer.
/// Always near-black regardless of theme, since the image is the focus.
var imageViewerBackgroundColor: UIColor {
UIColor(white: 0.0, alpha: 0.95)
}
var themeBackgroundColorCSS: String {
contentBackgroundColor.rd_cssString
}
var themeTextColorCSS: String {
contentTextColor.rd_cssString
}
}
@@ -0,0 +1,17 @@
import Foundation
import CryptoKit
extension String {
/// trim nil
var rd_nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
/// SHA256 hex
var rd_sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}
@@ -0,0 +1,114 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// Chapter-level shared display content and layouter
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
///
/// One entry holds the chapter-length display string (theme text color and
/// dark-image adjustment injected once over the full chapter at build time)
/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
/// the shared string and request per-page layout frames; they must never
/// mutate the shared string the framesetter and any live layout frame hold
/// it. Page-level decoration (highlights, search, selection) is drawn by the
/// overlay views instead.
///
/// Main-thread only.
final class RDEPUBChapterDisplayContentCache {
#if canImport(DTCoreText)
struct Entry {
let content: NSAttributedString
let layouter: DTCoreTextLayouter?
fileprivate let signature: Signature
}
fileprivate struct Signature: Equatable {
let chapterContentID: ObjectIdentifier
let contentLength: Int
let textColor: UIColor
let backgroundColor: UIColor
let darkImageAdjustmentEnabled: Bool
let darkImageBlendRatio: CGFloat
init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
chapterContentID = ObjectIdentifier(page.chapterContent)
contentLength = page.chapterContent.length
textColor = configuration.theme.contentTextColor
backgroundColor = configuration.theme.contentBackgroundColor
darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
darkImageBlendRatio = configuration.darkImageBlendRatio
}
}
/// Current chapter plus one adjacent chapter during page-curl/scroll
/// transitions across a chapter boundary.
private static let capacity = 2
private var entries: [String: Entry] = [:]
private var accessOrder: [String] = []
func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
dispatchPrecondition(condition: .onQueue(.main))
let signature = Signature(page: page, configuration: configuration)
if let cached = entries[page.href], cached.signature == signature {
touch(page.href)
return cached
}
let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
entries[page.href] = entry
touch(page.href)
evictIfNeeded()
RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
return entry
}
func removeAll() {
entries.removeAll()
accessOrder.removeAll()
}
private func touch(_ href: String) {
accessOrder.removeAll { $0 == href }
accessOrder.append(href)
}
private func evictIfNeeded() {
while accessOrder.count > Self.capacity {
let evicted = accessOrder.removeFirst()
entries.removeValue(forKey: evicted)
}
}
private static func buildEntry(
page: RDEPUBTextPage,
configuration: RDEPUBReaderConfiguration,
signature: Signature
) -> Entry {
let content = NSMutableAttributedString(attributedString: page.chapterContent)
let fullRange = NSRange(location: 0, length: content.length)
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
content,
in: fullRange,
configuration: configuration
)
content.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: fullRange
)
let layouter = DTCoreTextLayouter(attributedString: content)
layouter?.shouldCacheLayoutFrames = false
return Entry(content: content, layouter: layouter, signature: signature)
}
#else
func removeAll() {}
#endif
}
@@ -0,0 +1,107 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
enum RDEPUBDarkImageAdjuster {
private static let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 52_428_800 // 50 MB
return cache
}()
private static func imageCost(of image: UIImage) -> Int {
let scale = image.scale
let width = Int(image.size.width * scale)
let height = Int(image.size.height * scale)
// 4 bytes per pixel (RGBA)
return width * height * 4
}
#if canImport(DTCoreText)
static func adjustIfNeeded(
_ content: NSMutableAttributedString,
in range: NSRange? = nil,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkBackground else {
return content
}
let fullRange = NSRange(location: 0, length: content.length)
let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjust(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: cacheKey(for: attachment, image: image, configuration: configuration)
)
adjustedAttachment.originalSize = attachment.originalSize
adjustedAttachment.displaySize = attachment.displaySize
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
adjustedAttachment.contentURL = attachment.contentURL
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
}
private static func shouldAdjust(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private static func cacheKey(
for attachment: DTImageTextAttachment,
image: UIImage,
configuration: RDEPUBReaderConfiguration
) -> NSString {
let source = attachment.contentURL?.absoluteString
?? "\(Unmanaged.passUnretained(image).toOpaque())"
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private static func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = imageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
let adjusted = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: image.size))
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
return adjusted
}
#endif
}
@@ -0,0 +1,215 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
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 {
snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page)
} else {
snapshot = nil
}
}
#endif
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
return characterIndex(at: localPoint)
}
func characterIndex(at point: CGPoint) -> Int? {
guard let snapshot else { return nil }
for attachment in snapshot.attachments {
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
return attachment.stringRange.location
}
}
guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil }
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let relativePoint = CGPoint(
x: point.x - line.baselineOrigin.x,
y: point.y - line.baselineOrigin.y
)
// The layout frame is built in chapter context, so DTCoreText string
// indices are chapter-absolute already.
let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex(
idx,
lineRange: line.stringRange,
pageRange: snapshot.pageContentRange
)
#else
return nil
#endif
}
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
guard let start = characterIndex(at: startPoint),
let end = characterIndex(at: endPoint) else { return nil }
let lower = min(start, end)
let upper = max(start, end)
return NSRange(location: lower, length: max(upper - lower, 1))
}
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
guard let snapshot else { return [] }
var rects: [CGRect] = []
for line in snapshot.lines {
let overlap = NSIntersectionRange(line.stringRange, absoluteRange)
guard overlap.length > 0 else { continue }
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
let startX = dtLine.offset(forStringIndex: overlap.location)
let endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx)
#else
let startX: CGFloat = 0
let endX: CGFloat = line.frame.width
#endif
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: max(endX - startX, 2),
height: line.ascent + line.descent
)
rects.append(rect)
}
return mergeAdjacentRects(rects)
}
func firstRect(for absoluteRange: NSRange) -> CGRect? {
selectionRects(for: absoluteRange).first
}
func lastRect(for absoluteRange: NSRange) -> CGRect? {
selectionRects(for: absoluteRange).last
}
func boundingRect(for absoluteRange: NSRange) -> CGRect? {
let rects = selectionRects(for: absoluteRange)
guard var rect = rects.first else { return nil }
for next in rects.dropFirst() {
rect = rect.union(next)
}
return rect
}
func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? {
guard let first = firstRect(for: absoluteRange),
let last = lastRect(for: absoluteRange) else {
return boundingRect(for: absoluteRange)
}
let minX = min(first.minX, last.minX)
let maxX = max(first.maxX, last.maxX)
let minY = min(first.minY, last.minY)
let maxY = max(first.maxY, last.maxY)
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
}
func caretRect(at index: Int) -> CGRect? {
guard let snapshot else { return nil }
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
return nil
}
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let offsetX = dtLine.offset(forStringIndex: index)
#else
let offsetX: CGFloat = 0
#endif
return CGRect(
x: line.baselineOrigin.x + offsetX - 1,
y: line.baselineOrigin.y - line.ascent,
width: 2,
height: line.ascent + line.descent
)
}
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
var bestLine: RDEPUBPageLine?
var bestDistance: CGFloat = .greatestFiniteMagnitude
for line in lines {
let lineBottom = line.baselineOrigin.y + line.descent
let lineTop = line.baselineOrigin.y - line.ascent
if point.y >= lineTop && point.y <= lineBottom {
return line
}
let lineMidY = (lineTop + lineBottom) / 2
let distance = abs(point.y - lineMidY)
if distance < bestDistance {
bestDistance = distance
bestLine = line
}
}
return bestLine
}
private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int {
var result = idx
if result < lineRange.location {
result = lineRange.location
}
let lineEnd = lineRange.location + lineRange.length
if result >= lineEnd {
result = max(lineEnd - 1, lineRange.location)
}
return result
}
#if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(max(range.location, 0)))
}
#endif
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects }
let sorted = rects.sorted { a, b in
if abs(a.origin.y - b.origin.y) < 1 {
return a.origin.x < b.origin.x
}
return a.origin.y < b.origin.y
}
var merged: [CGRect] = [sorted[0]]
for rect in sorted.dropFirst() {
let last = merged[merged.count - 1]
if abs(rect.origin.y - last.origin.y) < 1,
rect.origin.x <= last.maxX + 2 {
merged[merged.count - 1] = last.union(rect)
} else {
merged.append(rect)
}
}
return merged
}
}
@@ -0,0 +1,224 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
struct RDEPUBPageLine {
let stringRange: NSRange
let frame: CGRect
let baselineOrigin: CGPoint
let ascent: CGFloat
let descent: CGFloat
let leading: CGFloat
}
struct RDEPUBPageRun {
let stringRange: NSRange
let frame: CGRect
let isAttachment: Bool
}
struct RDEPUBPageAttachment {
let stringRange: NSRange
let frame: CGRect
let displaySize: CGSize
let placement: RDEPUBTextAttachmentPlacement?
let kind: RDEPUBTextAttachmentKind?
}
struct RDEPUBPageLayoutSnapshot {
let page: RDEPUBTextPage
let lines: [RDEPUBPageLine]
let runs: [RDEPUBPageRun]
let attachments: [RDEPUBPageAttachment]
let pageContentRange: NSRange
#if canImport(DTCoreText)
let layoutFrame: DTCoreTextLayoutFrame
#endif
var contentBounds: CGRect {
let rects = lines.map(\.frame) + attachments.map(\.frame)
guard var bounds = rects.first else { return .zero }
for rect in rects.dropFirst() {
bounds = bounds.union(rect)
}
return bounds
}
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
}
func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
}
func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
}
func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
lines.first { line in
let lineRect = CGRect(
x: line.frame.minX,
y: line.baselineOrigin.y - line.ascent,
width: max(line.frame.width, 1),
height: line.ascent + line.descent + line.leading
)
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
}
}
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
decorations.filter { decoration in
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
}
}
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
if let attachment = attachment(at: point) {
return attachment.stringRange
}
if let run = run(at: point) {
return run.stringRange
}
let hitDecorations = rects(containing: point, in: decorations)
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
lhs.absoluteRange.length < rhs.absoluteRange.length
}) {
return mostSpecific.absoluteRange
}
return line(at: point)?.stringRange
}
#if canImport(DTCoreText)
static func build(
from layoutFrame: DTCoreTextLayoutFrame,
page: RDEPUBTextPage
) -> RDEPUBPageLayoutSnapshot? {
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
return nil
}
// The layout frame is produced in chapter context, so DTCoreText
// string ranges are already chapter-absolute.
var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = []
for dtLine in dtLines {
let lineRange = dtLine.stringRange()
let line = RDEPUBPageLine(
stringRange: lineRange,
frame: dtLine.frame,
baselineOrigin: dtLine.baselineOrigin,
ascent: dtLine.ascent,
descent: dtLine.descent,
leading: dtLine.leading
)
lines.append(line)
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
for run in glyphRuns {
let runRange = run.stringRange()
let isAttachment = run.attachment != nil
runs.append(
RDEPUBPageRun(
stringRange: runRange,
frame: run.frame,
isAttachment: isAttachment
)
)
guard isAttachment else { continue }
let metadata = attachmentMetadata(
for: runRange,
on: page
)
attachments.append(
RDEPUBPageAttachment(
stringRange: runRange,
frame: run.frame,
displaySize: run.frame.size,
placement: metadata.placement,
kind: metadata.kind
)
)
}
}
}
let visibleRange = layoutFrame.visibleStringRange()
return RDEPUBPageLayoutSnapshot(
page: page,
lines: lines,
runs: runs,
attachments: attachments,
pageContentRange: visibleRange,
layoutFrame: layoutFrame
)
}
private static func attachmentMetadata(
for range: NSRange,
on page: RDEPUBTextPage
) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
return (nil, nil)
}
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
? page.metadata.attachmentPlacements[attachmentIndex]
: nil
let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
? page.metadata.attachmentKinds[attachmentIndex]
: nil
let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind
return (placement, resolvedKind)
}
private static func attachmentKind(
at range: NSRange,
on page: RDEPUBTextPage
) -> RDEPUBTextAttachmentKind? {
guard range.location >= 0,
range.location < page.chapterContent.length else {
return nil
}
let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil)
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
}
#endif
}
@@ -0,0 +1,78 @@
import UIKit
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)
}
}
}
@@ -0,0 +1,177 @@
import UIKit
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
}
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) {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
self.page = page
self.snapshot = snapshot
self.selectionColor = selectionColor
selectionRange = nil
decorations = []
setNeedsDisplay()
}
func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
self.snapshot = snapshot
}
func updateSelection(absoluteRange: NSRange?) {
selectionRange = absoluteRange
selectionRects = []
setNeedsDisplay()
}
func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
selectionRange = absoluteRange
selectionRects = rects
setNeedsDisplay()
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations
setNeedsDisplay()
}
func clearSelection() {
updateSelection(absoluteRange: nil)
}
func absoluteRange(at point: CGPoint) -> NSRange? {
if let selectionRange,
selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
return selectionRange
}
if let snapshot,
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
return absoluteRange
}
for decoration in resolvedDecorations {
for rect in decoration.rects {
if rect.insetBy(dx: -4, dy: -4).contains(point) {
return decoration.absoluteRange
}
}
}
return nil
}
func decorationSummary() -> String {
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
let highlightCount = counts[.highlight, default: 0]
let underlineCount = counts[.underline, default: 0]
let searchCount = counts[.search, default: 0]
let activeSearchCount = counts[.activeSearch, default: 0]
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
return [
"selection \(selectionLabel)",
"highlight \(highlightCount)",
"underline \(underlineCount)",
"search \(searchCount)",
"activeSearch \(activeSearchCount)",
"locate \(locateLabel)"
].joined(separator: " · ")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in resolvedDecorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
guard page != nil else { return [] }
var result = decorations.filter { !$0.rects.isEmpty }
if let selectionRange, !selectionRects.isEmpty {
result.append(
RDEPUBTextOverlayDecoration(
kind: .selection,
absoluteRange: selectionRange,
rects: selectionRects,
color: selectionColor
)
)
}
return result
}
}
@@ -0,0 +1,135 @@
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)
func applyHighlights(
_ highlights: [RDEPUBHighlight],
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for highlight in highlights where highlight.location.href == page.href {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
let overlapStart = max(range.location, pageStart)
let overlapEnd = min(range.location + range.length, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(
location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
case .highlight:
content.addAttribute(
.backgroundColor,
value: UIColor(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35),
range: relativeRange
)
case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange)
}
}
}
}
func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?,
contentBaseOffset: Int
) {
guard let searchState else { return }
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
}
func buildDecorations(
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight],
searchState: RDEPUBSearchState?,
interactionController: RDEPUBPageInteractionController
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
var background: [RDEPUBTextOverlayDecoration] = []
var foreground: [RDEPUBTextOverlayDecoration] = []
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
if let searchState {
for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue }
let isActive = match == searchState.currentMatch
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
let color = isActive ? activeSearchColor : normalSearchColor
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
}
}
for highlight in highlights where highlight.location.href == page.href {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
let overlapStart = max(range.location, pageStart)
let overlapEnd = min(range.location + range.length, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue }
let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
let decoration = RDEPUBTextOverlayDecoration(
kind: highlight.style == .underline ? .underline : .highlight,
absoluteRange: absoluteRange,
rects: rects,
color: color
)
if decoration.kind == .underline {
foreground.append(decoration)
} else {
background.append(decoration)
}
}
return (background, foreground)
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
}
@@ -0,0 +1,248 @@
import UIKit
final class RDEPUBTextContentInteractionCoordinator: NSObject {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
struct Dependencies {
let hasRenderableContent: () -> Bool
let currentSelectionProvider: () -> RDEPUBSelection?
let isSelectionControllerSelecting: () -> Bool
let hasActiveSelection: () -> Bool
let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle?
let selectionContainsPoint: (CGPoint) -> Bool
let renderPointForGesture: (UIGestureRecognizer) -> CGPoint
let renderPointForTouch: (UITouch) -> CGPoint
let performLongPressSelection: (UILongPressGestureRecognizer) -> Void
let performPanSelection: (UIPanGestureRecognizer) -> Void
let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void
let presentLoupeAtPoint: (CGPoint) -> Void
let dismissLoupe: () -> Void
let showSelectionMenu: () -> Void
let hideSelectionMenu: () -> Void
let selectionTapSuppressionDidChange: (Bool) -> Void
let selectionPagingSuppressionDidChange: (Bool) -> Void
}
private let dependencies: Dependencies
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private(set) var interactionState: SelectionInteractionState = .idle
var isInteractionInProgress: Bool {
interactionState != .idle
}
init(dependencies: Dependencies) {
self.dependencies = dependencies
super.init()
}
func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
dependencies.performLongPressSelection(gesture)
switch gesture.state {
case .began:
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .changed:
updateSelectionInteractionState(.selecting)
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .ended:
activeSelectionHandle = nil
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
dependencies.dismissLoupe()
default:
break
}
}
func handlePan(_ gesture: UIPanGestureRecognizer) {
let point = dependencies.renderPointForGesture(gesture)
if gesture.state == .began, activeSelectionHandle == nil,
let handle = dependencies.selectionHandleAtPoint(point) {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(point)
}
if let activeSelectionHandle {
dependencies.adjustSelection(activeSelectionHandle, point)
dependencies.presentLoupeAtPoint(point)
} else {
dependencies.performPanSelection(gesture)
if dependencies.isSelectionControllerSelecting() {
dependencies.presentLoupeAtPoint(point)
}
}
switch gesture.state {
case .ended:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
default:
break
}
}
func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard interactionState == .idle,
activeSelectionHandle == nil,
dependencies.hasRenderableContent(),
let touch = touches.first else {
return
}
let point = dependencies.renderPointForTouch(touch)
guard dependencies.selectionHandleAtPoint(point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
}
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer {
if dependencies.isSelectionControllerSelecting() {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
return dependencies.selectionHandleAtPoint(point) != nil
}
if gestureRecognizer is UILongPressGestureRecognizer {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
if dependencies.selectionHandleAtPoint(point) != nil {
return false
}
if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) {
return false
}
return true
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForTouch(touch)
if let handle = dependencies.selectionHandleAtPoint(point) {
if gestureRecognizer is UIPanGestureRecognizer {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
return true
}
if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer {
return false
}
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer
}
func reset() {
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
dependencies.dismissLoupe()
}
private func resetSelectionPendingIfNeeded() {
guard interactionState == .selectionPending else { return }
if dependencies.currentSelectionProvider() != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = interactionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
let previousState = interactionState
interactionState = state
let currentTapSuppressed = interactionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
if previousState != state {
RDEpubReaderTapDebug.log(
"TextContentInteraction.state",
"transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)"
)
}
if previousTapSuppressed != currentTapSuppressed {
dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// Debug-only detector for pagination/display metric mismatches, enabled by
/// the `--demo-pagination-validate` launch argument.
///
/// For every displayed text page it re-wraps the chapter text from the page
/// start at the display width (same CoreText engine the paginator used, in
/// chapter context) and compares the resulting line breaks against both the
/// page range and the lines actually drawn. Two failure classes:
///
/// - `STALE-RANGE`: the page range does not end on a line boundary of the
/// current metrics the range was produced under different metrics than
/// the ones on screen (stale page table).
/// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at
/// different offsets than the in-context wrap the display-side content
/// transform (e.g. continuation-paragraph normalization) changed wrapping.
enum RDEPUBTextPageBoundaryValidator {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate")
/// Extra characters wrapped past the page end so the probe can see the
/// line that a mid-line page boundary cuts through.
private static let probeTailLength = 400
static func validate(
page: RDEPUBTextPage,
displayLayoutFrame: DTCoreTextLayoutFrame,
displayContent: NSAttributedString?,
displayBounds: CGRect
) {
guard isEnabled else { return }
let chapter = page.chapterContent
let pageStart = page.contentRange.location
let pageEnd = page.contentRange.location + page.contentRange.length
guard page.contentRange.length > 0,
pageStart >= 0,
pageEnd <= chapter.length,
displayBounds.width > 0 else { return }
guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return }
layouter.shouldCacheLayoutFrames = false
let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength)
let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000)
guard let probeFrame = layouter.layoutFrame(
with: probeRect,
range: NSRange(location: pageStart, length: probeLength)
), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return }
let probeRanges = probeLines.map { $0.stringRange() }
// Class A: the page must end on a line boundary of the current wrap
// (unless it is the chapter's last page, which ends at chapter end).
let isChapterLastPage = pageEnd >= chapter.length
if !isChapterLastPage,
!probeRanges.contains(where: { NSMaxRange($0) == pageEnd }),
let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) {
let text = chapter.string as NSString
let lineText = safeSubstring(text, cutLine)
print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"")
}
// Class B: the drawn lines must break at the same offsets as the
// in-context wrap. The display layout frame is built in chapter
// context, so its string ranges are chapter-absolute.
guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return }
for (index, displayLine) in displayLines.enumerated() {
let displayRange = displayLine.stringRange()
let displayEndInChapter = NSMaxRange(displayRange)
guard displayEndInChapter < pageEnd else { break }
guard index < probeRanges.count else { break }
let probeEnd = NSMaxRange(probeRanges[index])
if probeEnd != displayEndInChapter {
let text = chapter.string as NSString
let lineStartInChapter = displayRange.location
let displayLineRangeInChapter = displayRange
let isParagraphStart = lineStartInChapter == 0
|| text.character(at: lineStartInChapter - 1) == 0x0A
let chapterStyle = chapter.attribute(
.paragraphStyle, at: lineStartInChapter, effectiveRange: nil
) as? NSParagraphStyle
let displayStyle = displayContent?.attribute(
.paragraphStyle, at: displayRange.location, effectiveRange: nil
) as? NSParagraphStyle
let displayLineWidth = displayLine.frame.width
let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1
print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"")
diagnoseDivergence(
page: page,
displayContent: displayContent,
lineIndex: index,
lineStartInChapter: lineStartInChapter,
displayEndInChapter: displayEndInChapter,
probeEnd: probeEnd,
width: displayBounds.width
)
break
}
}
}
/// Narrows a display/probe line-break divergence down to its cause by
/// re-wrapping controlled variants and diffing attributes over the line.
private static func diagnoseDivergence(
page: RDEPUBTextPage,
displayContent: NSAttributedString?,
lineIndex: Int,
lineStartInChapter: Int,
displayEndInChapter: Int,
probeEnd: Int,
width: CGFloat
) {
let chapter = page.chapterContent
// Variant 1: the raw page substring with no display normalization.
let rawSubstring = chapter.attributedSubstring(from: page.contentRange)
let rawEnd = lineEnd(
wrapping: rawSubstring,
lineIndex: lineIndex,
width: width
).map { $0 + page.pageStartOffset }
// Variant 2: wrap the chapter from the start of the paragraph that
// contains the diverging line (context = current paragraph only).
let text = chapter.string as NSString
let paragraphRange = text.paragraphRange(
for: NSRange(location: lineStartInChapter, length: 0)
)
let paraString = chapter.attributedSubstring(
from: NSRange(
location: paragraphRange.location,
length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength)
)
)
var paraEnd: Int?
if let layouter = DTCoreTextLayouter(attributedString: paraString) {
layouter.shouldCacheLayoutFrames = false
let frame = layouter.layoutFrame(
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
range: NSRange(location: 0, length: paraString.length)
)
if let lines = frame?.lines as? [DTCoreTextLayoutLine] {
let target = lineStartInChapter - paragraphRange.location
if let matched = lines.first(where: { $0.stringRange().location == target }) {
paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location
}
}
}
print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)")
// Attribute diff between chapter text and display content over the
// diverging line (through the longer of the two ends). The display
// content is a chapter-length copy, so indices are shared.
guard let displayContent else { return }
let diffEnd = max(displayEndInChapter, probeEnd)
var position = lineStartInChapter
while position < diffEnd {
guard position >= 0, position < displayContent.length, position < chapter.length else { break }
var chapterRunRange = NSRange()
let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange)
var displayRunRange = NSRange()
let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange)
let keys = Set(chapterAttrs.keys).union(displayAttrs.keys)
for key in keys {
let lhs = chapterAttrs[key] as AnyObject?
let rhs = displayAttrs[key] as AnyObject?
if let lhs, let rhs, lhs.isEqual(rhs) { continue }
if lhs == nil, rhs == nil { continue }
print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))")
}
let nextPosition = min(
NSMaxRange(chapterRunRange),
NSMaxRange(displayRunRange)
)
guard nextPosition > position else { break }
position = nextPosition
}
}
private static func describeAttr(_ value: AnyObject?) -> String {
guard let value else { return "nil" }
if let font = value as? UIFont {
return "font(\(font.fontName),\(font.pointSize))"
}
if let style = value as? NSParagraphStyle {
return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))"
}
if let number = value as? NSNumber {
return "num(\(number))"
}
return String(describing: type(of: value))
}
/// Wraps `content` page-locally and returns the chapter-relative end of
/// line `lineIndex`, or nil if it cannot be produced.
private static func lineEnd(
wrapping content: NSAttributedString,
lineIndex: Int,
width: CGFloat
) -> Int? {
guard content.length > 0,
let layouter = DTCoreTextLayouter(attributedString: content) else { return nil }
layouter.shouldCacheLayoutFrames = false
let frame = layouter.layoutFrame(
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
range: NSRange(location: 0, length: content.length)
)
guard let lines = frame?.lines as? [DTCoreTextLayoutLine],
lineIndex < lines.count else { return nil }
return NSMaxRange(lines[lineIndex].stringRange())
}
private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String {
guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" }
return text.substring(with: range)
.replacingOccurrences(of: "\n", with: "")
}
}
#endif
@@ -0,0 +1,3 @@
import UIKit
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
@@ -0,0 +1,213 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
final class RDEPUBTextPageRenderView: UIView {
enum SelectionHandle {
case start
case end
}
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
invalidateStaticContent()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
invalidateStaticContent()
}
}
var selectionRects: [CGRect] = [] {
didSet {
setNeedsDisplay()
}
}
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
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
private var cachedStaticImage: UIImage?
private var cachedStaticBoundsSize: CGSize = .zero
private var needsStaticContentRedraw = true
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
if cachedStaticImage == nil
|| cachedStaticBoundsSize != bounds.size
|| needsStaticContentRedraw {
cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
cachedStaticBoundsSize = bounds.size
needsStaticContentRedraw = false
}
if let cachedStaticImage {
cachedStaticImage.draw(in: bounds)
} else {
layoutFrame.draw(in: context, options: drawOptions)
}
drawSelection(in: context)
context.restoreGState()
}
override func layoutSubviews() {
super.layoutSubviews()
if cachedStaticBoundsSize != bounds.size {
invalidateStaticContent()
}
}
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 func invalidateStaticContent() {
cachedStaticImage = nil
needsStaticContentRedraw = true
setNeedsDisplay()
}
private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat.default()
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
return renderer.image { _ in
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
layoutFrame.draw(in: staticContext, options: drawOptions)
}
}
}
private extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
hypot(x - point.x, y - point.y)
}
}
#endif
@@ -0,0 +1,57 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// Redistributes the leftover space at the bottom of a page into the gaps
/// between lines so the last line sits flush with the content bottom edge
/// (vertical justification), keeping page character ranges untouched.
///
/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives
/// line frames, glyph-run frames and attachment positions from it lazily,
/// so drawing, selection, highlights and hit-testing all stay consistent.
enum RDEPUBTextPageVerticalJustifier {
/// Leftover larger than this many typical line advances is kept as
/// whitespace instead of being stretched: it usually comes from a whole
/// block (image, table) pushed to the next page, and stretching would
/// make the line spacing visibly sparse.
static let maxStretchLineAdvanceRatio: CGFloat = 1.5
static func justify(
_ layoutFrame: DTCoreTextLayoutFrame,
contentHeight: CGFloat,
isChapterLastPage: Bool,
pixelScale: CGFloat
) {
guard !isChapterLastPage,
contentHeight > 0,
let lines = layoutFrame.lines as? [DTCoreTextLayoutLine],
lines.count >= 2,
let firstLine = lines.first,
let lastLine = lines.last else {
return
}
let leftover = contentHeight - lastLine.frame.maxY
guard leftover > 0.5 else { return }
let gapCount = CGFloat(lines.count - 1)
let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount
guard typicalAdvance > 0,
leftover <= typicalAdvance * maxStretchLineAdvanceRatio else {
return
}
let scale = max(pixelScale, 1)
for (index, line) in lines.enumerated() where index > 0 {
// Round each cumulative shift down to the pixel grid so glyphs
// stay sharp and the last line never overshoots the bottom edge.
let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale
var origin = line.baselineOrigin
origin.y += shift
line.baselineOrigin = origin
}
}
}
#endif
@@ -0,0 +1,388 @@
import UIKit
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
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
}
var selectedAbsoluteRange: NSRange? {
guard selectionStartIndex != NSNotFound,
selectionEndIndex != NSNotFound else {
return nil
}
let lower = min(selectionStartIndex, selectionEndIndex)
let upper = max(selectionStartIndex, selectionEndIndex)
return NSRange(location: lower, length: max(upper - lower + 1, 1))
}
func handleLongPress(
_ gesture: UILongPressGestureRecognizer,
renderView: RDEPUBTextPageRenderView?,
interactionController: RDEPUBPageInteractionController
) {
guard let renderView else { return }
let point = gesture.location(in: renderView)
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:
break
}
}
func handlePan(
_ gesture: UIPanGestureRecognizer,
renderView: RDEPUBTextPageRenderView?,
interactionController: RDEPUBPageInteractionController
) {
guard isSelecting, let renderView else { return }
let point = gesture.location(in: renderView)
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)
}
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
isSelecting = false
selectionAnchorIndex = NSNotFound
activeGranularity = .character
selectionStartIndex = NSNotFound
selectionEndIndex = NSNotFound
setInteractionState(.idle)
renderView?.selectionRects = []
onSelectionChanged?(nil)
}
private func beginSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
if !isSelecting {
clearSelection(renderView: renderView)
}
return
}
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)
}
private func updateSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard selectionAnchorIndex != NSNotFound,
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
return
}
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)
}
private func applySelection(
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let absoluteRange = selectedAbsoluteRange,
let page = pageProvider?() else {
clearSelection(renderView: renderView)
return
}
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,
NSMaxRange(absoluteRange) <= page.chapterContent.length else {
return nil
}
let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length
let chapterData = makeChapterData(for: page)
let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
return RDEPUBSelection(
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])
)
}
}
@@ -0,0 +1,31 @@
import UIKit
extension UIColor {
/// rgba CSS "rgba(255, 128, 0, 1.000)"
var rd_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
}
/// #RRGGBB hex
var rd_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
}
/// BT.709 < 0.4
var rd_isDarkBackground: 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 }
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.4
}
}
@@ -0,0 +1,16 @@
import UIKit
extension UIColor {
convenience init?(rdHexString: String, alpha: CGFloat) {
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
}