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:
@@ -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 独立试读 epub(整本都是试读章节):readableChapterCount 传 nil,墙追加在全书末尾;
|
||||
/// - 整本 epub + 按章限制:readableChapterCount 传 N,超出章节页不展示(仅 bookPageMap 路径)。
|
||||
extension RDEPUBReaderController {
|
||||
|
||||
/// 试读墙视图(首次访问经 delegate 获取并缓存)
|
||||
var trialWallView: UIView? {
|
||||
if let cached = cachedTrialWallView {
|
||||
return cached
|
||||
}
|
||||
guard configuration.trialPolicy != nil else { return nil }
|
||||
let view = delegate?.epubReaderTrialWallView(self)
|
||||
cachedTrialWallView = view
|
||||
return view
|
||||
}
|
||||
|
||||
/// 是否启用试读墙:配置了试读策略且宿主提供了墙视图
|
||||
var isTrialWallEnabled: Bool {
|
||||
configuration.trialPolicy != nil && trialWallView != nil
|
||||
}
|
||||
|
||||
/// 原始可读内容页数(不含试读墙页)。
|
||||
/// readableChapterCount 指定且存在 bookPageMap 时按章截断,否则为全部内容页。
|
||||
var readableContentPageCount: Int {
|
||||
let rawCount = pageCountOfReaderView(readerView: readerView)
|
||||
guard let policy = configuration.trialPolicy,
|
||||
let readableChapterCount = policy.readableChapterCount,
|
||||
readableChapterCount > 0,
|
||||
let pageMap = readerContext.bookPageMap else {
|
||||
return rawCount
|
||||
}
|
||||
// 第一个不可读章节(spineIndex >= readableChapterCount)的起始绝对页 = 可读页数
|
||||
let firstLockedStart = pageMap.entries
|
||||
.first { $0.spineIndex >= readableChapterCount }?
|
||||
.absolutePageStart
|
||||
return firstLockedStart ?? rawCount
|
||||
}
|
||||
|
||||
/// 试读墙页的绝对索引(0-based);未启用时为 nil
|
||||
var trialWallPageIndex: Int? {
|
||||
guard isTrialWallEnabled else { return nil }
|
||||
return readableContentPageCount
|
||||
}
|
||||
|
||||
/// 供 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.01:CALayer.opacity 为 Float32,0.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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)")
|
||||
}
|
||||
}
|
||||
+112
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
|
||||
let bookID: String
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+838
@@ -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)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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 }
|
||||
}
|
||||
+69
@@ -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
|
||||
}
|
||||
}
|
||||
+203
@@ -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()
|
||||
}
|
||||
}
|
||||
+228
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+679
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
+178
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
+230
@@ -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
|
||||
}
|
||||
}
|
||||
+578
@@ -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
|
||||
}
|
||||
}
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
+125
@@ -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
|
||||
}
|
||||
}
|
||||
+392
@@ -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)
|
||||
}
|
||||
}
|
||||
+248
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user