feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
|
||||
match.numberOfRanges > 2 else {
|
||||
return nil
|
||||
}
|
||||
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
|
||||
return nsAttributes.substring(with: match.range(at: 2)).rd_nilIfEmpty
|
||||
}
|
||||
|
||||
static func isIgnorableTag(_ tagName: String) -> Bool {
|
||||
@@ -138,8 +138,8 @@ enum RDEPUBCFITextNodeMapBuilder {
|
||||
markers: markers,
|
||||
pathRanges: pathRanges,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: rawHTML.sha256Hex,
|
||||
normalizedTextChecksum: normalizedChapter.normalizedText.sha256Hex,
|
||||
domFingerprint: rawHTML.rd_sha256Hex,
|
||||
normalizedTextChecksum: normalizedChapter.normalizedText.rd_sha256Hex,
|
||||
tokenIndex: makeTokenAnchors(
|
||||
normalizedChapter: normalizedChapter,
|
||||
markers: markers,
|
||||
@@ -516,14 +516,6 @@ private struct RDEPUBNormalizedTextIndex {
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var rd_cfiNilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var fnv1a64: UInt64 {
|
||||
let offsetBasis: UInt64 = 14695981039346656037
|
||||
|
||||
@@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
|
||||
if let open = rawStep.firstIndex(of: "["),
|
||||
let close = rawStep.lastIndex(of: "]"),
|
||||
open < close {
|
||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
|
||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_nilIfEmpty
|
||||
} else {
|
||||
idAssertion = nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||
|
||||
public init(index: Int, idAssertion: String? = nil) {
|
||||
self.index = index
|
||||
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
|
||||
self.idAssertion = idAssertion?.rd_nilIfEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
|
||||
if cfi.packagePath.steps.count >= 3 {
|
||||
manifestStep = cfi.packagePath.steps[2]
|
||||
} else {
|
||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
|
||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })
|
||||
}
|
||||
let href = manifestStep?.idAssertion
|
||||
|
||||
@@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
|
||||
fileIndex = nil
|
||||
}
|
||||
|
||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
|
||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })?.idAssertion
|
||||
return RDEPUBCFIResolverResult(
|
||||
href: href,
|
||||
fileIndex: fileIndex,
|
||||
|
||||
@@ -41,7 +41,7 @@ public struct RDEPUBNoteReference: Codable, Equatable {
|
||||
self.targetHref = targetHref
|
||||
self.targetFragment = targetFragment
|
||||
self.targetCFI = targetCFI
|
||||
self.label = label?.rdNoteNilIfEmpty
|
||||
self.label = label?.rd_nilIfEmpty
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,8 @@ public struct RDEPUBResolvedNote: Equatable {
|
||||
self.reference = reference
|
||||
self.sourceLocation = sourceLocation
|
||||
self.targetLocation = targetLocation
|
||||
self.title = title?.rdNoteNilIfEmpty
|
||||
self.title = title?.rd_nilIfEmpty
|
||||
self.html = html
|
||||
self.plainText = plainText
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var rdNoteNilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,11 @@ public final class RDEPUBReadingSession {
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
|
||||
let resource = RDEPUBViewportResource(
|
||||
|
||||
@@ -96,15 +96,3 @@ extension RDEPUBWebView {
|
||||
currentLoadSignature = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
|
||||
var ss_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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ extension RDEPUBWebView {
|
||||
spread: spread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: contentInset,
|
||||
backgroundColorCSS: backgroundColor?.ss_hexString,
|
||||
backgroundColorCSS: backgroundColor?.rd_hexString,
|
||||
fit: .page,
|
||||
searchPresentation: nil
|
||||
)
|
||||
@@ -50,7 +50,7 @@ extension RDEPUBWebView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||
webView.loadHTMLString(
|
||||
html,
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///")
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,3 @@ extension NSAttributedString {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
|
||||
var ss_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ public struct RDEPUBTextIndexTable {
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = anchor(for: cfi) {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let anchor = anchor(for: cfiRange.start) {
|
||||
return anchor
|
||||
}
|
||||
|
||||
@@ -111,6 +111,11 @@ public struct RDEPUBTextIndexTable {
|
||||
return anchor
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = anchor(for: cfi) {
|
||||
return anchor
|
||||
}
|
||||
|
||||
guard let chapterIndex = hrefToChapterIndex[location.href],
|
||||
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
||||
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
||||
|
||||
@@ -134,8 +134,8 @@ struct RDEPUBStyleSheetComposer {
|
||||
}
|
||||
|
||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||
let background = style.backgroundColor?.rd_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||
let text = style.textColor?.rd_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||||
html, body {
|
||||
background: \(background) !important;
|
||||
@@ -149,8 +149,8 @@ struct RDEPUBStyleSheetComposer {
|
||||
|
||||
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
|
||||
let text = style.textColor.map { "color: \($0.ss_cssString) !important;" } ?? ""
|
||||
let background = style.backgroundColor.map { "background: \($0.ss_cssString) !important;" } ?? ""
|
||||
let text = style.textColor.map { "color: \($0.rd_cssString) !important;" } ?? ""
|
||||
let background = style.backgroundColor.map { "background: \($0.rd_cssString) !important;" } ?? ""
|
||||
return """
|
||||
html, body {
|
||||
font-family: "\(style.font.familyName)" !important;
|
||||
|
||||
@@ -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,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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,210 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
func pageNumber(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,
|
||||
relativeToSpineIndex: nil,
|
||||
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 {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
return page + 1
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
return textBook.pageNumber(
|
||||
for: normalizedLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
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 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 {
|
||||
readingSession?.transition(to: .idle)
|
||||
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)
|
||||
}
|
||||
|
||||
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
|
||||
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
|
||||
}
|
||||
|
||||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||||
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
|
||||
return offset
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
||||
return cfiOffset
|
||||
}
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
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 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 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 openExternalURLIfAllowed(_ url: URL) {
|
||||
guard shouldAllowExternalURL(url) else { return }
|
||||
if configuration.requiresExternalLinkConfirmation {
|
||||
presentExternalLinkConfirmation(for: url)
|
||||
} else {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
||||
guard let publication else { return false }
|
||||
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
|
||||
@@ -354,317 +150,4 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
|
||||
}
|
||||
|
||||
private 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDEPUBReaderController {
|
||||
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: resolvedPage.chapterIndex,
|
||||
spineIndex: resolvedPage.chapter.spineIndex,
|
||||
href: resolvedPage.chapter.href,
|
||||
title: resolvedPage.chapter.title,
|
||||
attributedContent: resolvedPage.chapter.typesetAttributedString,
|
||||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
|
||||
pages: resolvedPage.chapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
|
||||
|
||||
var onBackgroundTap: (() -> Void)?
|
||||
|
||||
var tooltipView: RDEPUBAttachmentTooltipView? {
|
||||
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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipView: UIView {
|
||||
|
||||
enum ArrowPlacement {
|
||||
|
||||
case top
|
||||
|
||||
case bottom
|
||||
}
|
||||
|
||||
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
|
||||
|
||||
private let arrowSize = CGSize(width: 20, height: 10)
|
||||
|
||||
private let cornerRadius: CGFloat = 18
|
||||
|
||||
private(set) var minimumArrowX: CGFloat = 28
|
||||
|
||||
private var arrowTipX: CGFloat?
|
||||
|
||||
private var arrowPlacement: ArrowPlacement = .bottom
|
||||
|
||||
private let textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
forAbsolutePageNumber: pageNum + 1,
|
||||
allowSynchronousLoad: false
|
||||
)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
|
||||
let shouldAllowSynchronousFallback = (
|
||||
readerView.currentPage < 0 || readerView.currentPage == pageNum
|
||||
) && !readerView.isPageCurlTransitioning
|
||||
if resolvedPage == nil, shouldAllowSynchronousFallback {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"sync fallback requested page=\(pageNum + 1) currentPage=\(readerView.currentPage + 1) totalKnown=\(pageCountOfReaderView(readerView: readerView))"
|
||||
)
|
||||
_ = 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)
|
||||
@@ -69,6 +84,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"configureLoading page=\(pageNum + 1) currentPage=\(readerView.currentPage + 1) totalKnown=\(pageCountOfReaderView(readerView: readerView)) hasMap=\(readerContext.bookPageMap != nil)"
|
||||
)
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
@@ -341,9 +360,24 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
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
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PageEvent",
|
||||
"pageNum callback raw=\(pageNum + 1) effective=\(effectivePageNum + 1) previousCurrent=\(previousCurrentPage + 1) readerCurrent=\(readerView.currentPage + 1) totalPages=\(pageCountOfReaderView(readerView: readerView)) display=\(readerView.currentDisplayType)"
|
||||
)
|
||||
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PageEvent",
|
||||
"pageNum callback aborted dueToCurrentPageMutation raw=\(pageNum + 1) effective=\(effectivePageNum + 1)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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,223 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
|
||||
func pageNumber(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,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
let localPageIndex = resolvedLocalPageIndex(
|
||||
for: normalizedLocation,
|
||||
spineIndex: spineIndex,
|
||||
fallbackEntry: entry
|
||||
) ?? 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 {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
return page + 1
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
return textBook.pageNumber(
|
||||
for: normalizedLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
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 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 {
|
||||
readingSession?.transition(to: .idle)
|
||||
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) -> Int {
|
||||
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 anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
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
|
||||
) -> Int? {
|
||||
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry)
|
||||
|
||||
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 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])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
||||
|
||||
let overlayColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 0.92)
|
||||
@@ -422,21 +422,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_searchIsDarkBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
|
||||
return false
|
||||
}
|
||||
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
||||
return luminance < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
|
||||
+176
-41
@@ -2,10 +2,21 @@ import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
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) {
|
||||
@@ -25,31 +36,70 @@ final class RDEPUBChapterLoader {
|
||||
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
|
||||
) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "request spine=\(spineIndex) priority=\(priority)")
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
if let context {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
let completionOnMain: LoadCompletion = { result in
|
||||
DispatchQueue.main.async {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: priority,
|
||||
completion: completionOnMain
|
||||
)
|
||||
|
||||
switch registration {
|
||||
case .joined(let existingPriority, let effectivePriority):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"dedupe spine=\(spineIndex) existingPriority=\(existingPriority) requestedPriority=\(priority) effectivePriority=\(effectivePriority)"
|
||||
)
|
||||
return
|
||||
case .created:
|
||||
break
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
_ = store.beginPendingChapterLoad(for: spineIndex)
|
||||
|
||||
store.chapterLoadQueue.async { [self] in
|
||||
guard 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
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(queuePriority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
@@ -71,12 +121,13 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
"buildChapter spine=\(spineIndex) priority=\(queuePriority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
) {
|
||||
try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
@@ -96,42 +147,38 @@ final class RDEPUBChapterLoader {
|
||||
store: store
|
||||
)
|
||||
|
||||
switch priority {
|
||||
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.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .preview:
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .prefetch:
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
}
|
||||
} catch {
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +187,15 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) 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),
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
@@ -155,6 +206,26 @@ final class RDEPUBChapterLoader {
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"sync join spine=\(spineIndex) existingPriority=\(existingPriority) effectivePriority=\(effectivePriority)"
|
||||
)
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
@@ -167,7 +238,7 @@ final class RDEPUBChapterLoader {
|
||||
"sync buildChapter spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
@@ -178,7 +249,8 @@ final class RDEPUBChapterLoader {
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
@@ -206,10 +278,48 @@ final class RDEPUBChapterLoader {
|
||||
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
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
@@ -231,7 +341,8 @@ final class RDEPUBChapterLoader {
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,7 +362,8 @@ final class RDEPUBChapterLoader {
|
||||
from: result.chapter,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
layoutConfig: layoutConfig,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,7 +375,8 @@ final class RDEPUBChapterLoader {
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
@@ -465,7 +578,8 @@ final class RDEPUBChapterLoader {
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
@@ -482,7 +596,7 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
@@ -498,7 +612,7 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
@@ -513,7 +627,7 @@ final class RDEPUBChapterLoader {
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex)
|
||||
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
@@ -540,7 +654,7 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
|
||||
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
@@ -586,12 +700,12 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||
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.sha256Hex
|
||||
return html.rd_sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
@@ -638,13 +752,34 @@ final class RDEPUBChapterLoader {
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
|
||||
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
|
||||
|
||||
+26
@@ -32,6 +32,10 @@ final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
private var pendingChapterLoadSpineIndices: Set<Int> = []
|
||||
|
||||
private let pendingChapterLoadLock = NSLock()
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
@@ -146,6 +150,25 @@ final class RDEPUBChapterRuntimeStore {
|
||||
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() }
|
||||
@@ -166,5 +189,8 @@ final class RDEPUBChapterRuntimeStore {
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.removeAll()
|
||||
cfiMapLock.unlock()
|
||||
pendingChapterLoadLock.lock()
|
||||
pendingChapterLoadSpineIndices.removeAll()
|
||||
pendingChapterLoadLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
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.sha256Hex
|
||||
let digest = rawKey.rd_sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.sha256Hex.prefix(12).lowercased()
|
||||
rawValue.rd_sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+139
-13
@@ -24,6 +24,14 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
private var isExtendingPartialBookPageMap = false
|
||||
|
||||
private let prepareRequestStateLock = NSLock()
|
||||
|
||||
private var pendingPreparePageNumbers: Set<Int> = []
|
||||
|
||||
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
@@ -58,6 +66,16 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
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,
|
||||
@@ -70,7 +88,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if store.chapterData(for: spineIndex) == nil {
|
||||
if !chapterReady {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: spineIndex,
|
||||
@@ -85,11 +103,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
store: store
|
||||
)
|
||||
} catch {
|
||||
clearPendingPreparePageNumber(pageNumber)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
markPrepareResolved(pageNumber)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
@@ -189,6 +209,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
|
||||
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
|
||||
@@ -226,9 +247,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
return true
|
||||
}
|
||||
|
||||
if let pendingMap = context.pendingFullPageMap,
|
||||
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
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(
|
||||
@@ -284,6 +306,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
asynchronouslyPreparingSpineIndices.removeAll()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.removeAll()
|
||||
recentPrepareTimestamps.removeAll()
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func applyAsyncPartialBookPageMapExtension(
|
||||
@@ -337,7 +363,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
presentationRuntime.applyExtendedPartialPageMap(
|
||||
presentationRuntime.queueExtendedPartialPageMap(
|
||||
newMap,
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
@@ -349,7 +375,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
triggerPageNumber: Int,
|
||||
completion: ((Bool) -> Void)?
|
||||
) {
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async deduped spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
)
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
@@ -363,11 +395,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
self.endAsynchronousChapterPreparation(for: spineIndex)
|
||||
switch result {
|
||||
case .success:
|
||||
self.markPrepareResolved(triggerPageNumber)
|
||||
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
case .failure(let error):
|
||||
self.clearPendingPreparePageNumber(triggerPageNumber)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
|
||||
@@ -389,7 +423,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
@@ -428,7 +462,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
|
||||
for targetSpineIndex in targets {
|
||||
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
|
||||
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(targetSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
@@ -441,7 +475,9 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
|
||||
minimumTrailingPages: Int = 2
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
@@ -449,8 +485,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
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(
|
||||
@@ -463,6 +504,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
projectedTotalPages += chapter.pages.count
|
||||
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
@@ -497,12 +542,68 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
"appendLoadedForwardChapters currentPage=\(currentPageNumber) trailingBefore=\(trailingPages) trailingAfter=\(newMap.totalPages - currentPageNumber) chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page deduped page=\(pageNumber) spine=\(spineIndex) chapterReady=false"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
|
||||
now - lastTimestamp <= prepareRequestDebounceInterval {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page debounced page=\(pageNumber) spine=\(spineIndex) chapterReady=\(chapterReady)"
|
||||
)
|
||||
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) {
|
||||
@@ -510,16 +611,41 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
let bookPageMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
if readerView.isPageCurlTransitioning {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"defer refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) reason=pageCurlTransition"
|
||||
)
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent matchedTrigger spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
guard visiblePageNumber > 0,
|
||||
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"skip refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent matchedVisibleSpine spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
|
||||
var sha256Hex: String {
|
||||
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
+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,572 @@
|
||||
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
|
||||
|
||||
unowned let 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)]
|
||||
|
||||
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 context = self.context
|
||||
let cancellationController = self.cancellationController
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||
guard context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(self.allBuildableIndices.count)")
|
||||
|
||||
let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"begin token=\(token.uuidString) buildableChapters=\(self.allBuildableIndices.count) concurrency=\(self.workerCount)"
|
||||
)
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(self.allBuildableIndices.count)"
|
||||
)
|
||||
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
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
|
||||
)
|
||||
} else {
|
||||
prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
}
|
||||
|
||||
let uncachedSpineIndices = prioritizedSpineIndices
|
||||
|
||||
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
||||
guard !cancellationController.isCancelled,
|
||||
context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
|
||||
return
|
||||
}
|
||||
|
||||
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.rdreader.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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
|
||||
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
|
||||
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()
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
|
||||
)
|
||||
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
|
||||
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||
snapshot = parseState.summariesBySpineIndex
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if let snapshot {
|
||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let partialMap = self.buildPageMap(summaries: snapshot)
|
||||
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||
timingLock.lock()
|
||||
totalMergeMs += mergeElapsed
|
||||
timingLock.unlock()
|
||||
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("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(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
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
||||
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(self.workerCount)"
|
||||
)
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = self.workerCount
|
||||
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||||
return
|
||||
}
|
||||
|
||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
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
|
||||
) {
|
||||
while context.controller != nil,
|
||||
cancellationController?.isCancelled != true,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry(
|
||||
spineIndex: Int,
|
||||
retryCount: Int,
|
||||
resultLock: NSLock,
|
||||
parseState: ParseState,
|
||||
cancellationController: RDEPUBMetadataParseCancellationController
|
||||
) {
|
||||
guard retryCount < Self.maxRetryCount else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
let context = self.context
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
|
||||
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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
|
||||
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)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == self.token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
|
||||
)
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: retryCount + 1,
|
||||
resultLock: resultLock,
|
||||
parseState: parseState,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ struct RDEPUBPaginationState {
|
||||
|
||||
var activePageMap: RDEPUBBookPageMap?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
enum RDEPUBPendingPageMapUpdateKind {
|
||||
case reconcileFullMap
|
||||
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
|
||||
case appendForward
|
||||
}
|
||||
|
||||
struct RDEPUBPendingPageMapUpdate {
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
let source: RDEPUBPaginationStateSource
|
||||
let kind: RDEPUBPendingPageMapUpdateKind
|
||||
}
|
||||
|
||||
final class RDEPUBPresentationRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
@@ -34,77 +46,63 @@ final class RDEPUBPresentationRuntime {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.pendingFullPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.pendingFullPageMap = nil
|
||||
paginationState.pendingPageMapUpdates.removeAll()
|
||||
paginationState.source = .initialPartial
|
||||
finishPagination(restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
if let pendingMap = context.pendingFullPageMap {
|
||||
let shouldKeepExisting =
|
||||
pendingMap.totalChapters > bookPageMap.totalChapters ||
|
||||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
|
||||
pendingMap.totalPages >= bookPageMap.totalPages)
|
||||
if shouldKeepExisting {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
paginationState.pendingFullPageMap = bookPageMap
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .pendingFullMap,
|
||||
kind: .reconcileFullMap
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
func commitPendingPageMapUpdateIfNeeded() {
|
||||
guard let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
guard !controller.isRepaginating else { return }
|
||||
guard !readerView.isPageCurlTransitioning else {
|
||||
RDEPUBBackgroundTrace.log("PageMapCommit", "defer commit reason=pageCurlTransition")
|
||||
return
|
||||
}
|
||||
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: pendingMap,
|
||||
candidateSegment: nil,
|
||||
currentWindow: context.bookPageMap,
|
||||
jumpSession: jumpSessionManager.activeSession
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .keepCurrentWindow:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
|
||||
|
||||
case .fullReplace(let newPageMap):
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
let rankedUpdates = rankedPendingPageMapUpdates()
|
||||
for (index, update) in rankedUpdates {
|
||||
if commitPendingPageMapUpdate(
|
||||
update,
|
||||
at: index,
|
||||
readerView: readerView,
|
||||
controller: controller
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyExtendedPartialPageMap(
|
||||
func queueExtendedPartialPageMap(
|
||||
_ bookPageMap: RDEPUBBookPageMap,
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let readerView = context.readerView else { return }
|
||||
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.source = .asyncExtension
|
||||
|
||||
readerView.reloadPageCountOnly()
|
||||
if let currentLocation,
|
||||
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
|
||||
return
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .asyncExtension,
|
||||
kind: .extendPartial(
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
@@ -115,6 +113,16 @@ final class RDEPUBPresentationRuntime {
|
||||
paginationState.source = .settingsPreview
|
||||
}
|
||||
|
||||
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .asyncExtension,
|
||||
kind: .appendForward
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
paginationState = RDEPUBPaginationState()
|
||||
navigationStateMachine.transition(to: .idle)
|
||||
@@ -150,22 +158,17 @@ final class RDEPUBPresentationRuntime {
|
||||
) {
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
context.pendingFullPageMap = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = newPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
|
||||
|
||||
paginationState.activePageMap = newPageMap
|
||||
paginationState.pendingFullPageMap = nil
|
||||
paginationState.source = .fullReplacement
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
|
||||
|
||||
if let currentLocation {
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
readerView.reloadPageCountOnly()
|
||||
if newPage != readerView.currentPage {
|
||||
readerView.transitionToPage(pageNum: newPage, animated: false)
|
||||
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
rebindVisiblePage(
|
||||
to: newPage,
|
||||
readerView: readerView
|
||||
)
|
||||
}
|
||||
} else {
|
||||
readerView.reloadPageCountOnly()
|
||||
@@ -181,4 +184,184 @@ final class RDEPUBPresentationRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
paginationState.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: RDReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) -> Bool {
|
||||
switch update.kind {
|
||||
case .reconcileFullMap:
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: update.pageMap,
|
||||
candidateSegment: nil,
|
||||
currentWindow: context.bookPageMap,
|
||||
jumpSession: jumpSessionManager.activeSession
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .keepCurrentWindow:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
|
||||
return false
|
||||
|
||||
case .fullReplace(let newPageMap):
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
return true
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
removePendingPageMapUpdate(at: index)
|
||||
return false
|
||||
}
|
||||
|
||||
case .extendPartial(let currentPageNumber, let currentLocation):
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap, source: update.source)
|
||||
if let currentLocation,
|
||||
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||
return true
|
||||
}
|
||||
rebindVisiblePage(
|
||||
to: max(currentPageNumber - 1, 0),
|
||||
readerView: readerView
|
||||
)
|
||||
return true
|
||||
|
||||
case .appendForward:
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap, source: update.source)
|
||||
readerView.reloadPageCountOnly()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
|
||||
if readerView.currentDisplayType == .pageCurl {
|
||||
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: RDReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) -> Bool {
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil,
|
||||
context.runtime?.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: targetPageNumber,
|
||||
allowSynchronousLoad: true
|
||||
) == false {
|
||||
return false
|
||||
}
|
||||
|
||||
rebindVisiblePage(
|
||||
to: max(targetPageNumber - 1, 0),
|
||||
readerView: readerView
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func applyPageMapToLiveModel(
|
||||
_ pageMap: RDEPUBBookPageMap,
|
||||
source: RDEPUBPaginationStateSource
|
||||
) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.bookPageMap = pageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
|
||||
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
|
||||
paginationState.activePageMap = pageMap
|
||||
paginationState.source = source
|
||||
}
|
||||
|
||||
private func removePendingPageMapUpdate(at index: Int) {
|
||||
var updates = context.pendingPageMapUpdates
|
||||
guard updates.indices.contains(index) else { return }
|
||||
updates.remove(at: index)
|
||||
context.pendingPageMapUpdates = updates
|
||||
paginationState.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
|
||||
paginationState.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +83,9 @@ final class RDEPUBReaderContext {
|
||||
set { state.searchState = newValue }
|
||||
}
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap? {
|
||||
get { state.pendingFullPageMap }
|
||||
set { state.pendingFullPageMap = newValue }
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
|
||||
get { state.pendingPageMapUpdates }
|
||||
set { state.pendingPageMapUpdates = newValue }
|
||||
}
|
||||
|
||||
var lastTextPaginationPageSize: CGSize? {
|
||||
@@ -249,7 +249,7 @@ final class RDEPUBReaderContext {
|
||||
let publication,
|
||||
publication.spine.indices.contains(spineIndex) {
|
||||
let href = publication.spine[spineIndex].href
|
||||
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
|
||||
+103
-640
@@ -2,77 +2,13 @@ import Foundation
|
||||
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
private final class MetadataParseState {
|
||||
|
||||
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 final class MetadataParseCancellationController {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let metadataParseControlLock = NSLock()
|
||||
|
||||
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
|
||||
private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
@@ -148,7 +84,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -167,7 +103,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -206,8 +142,34 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
if readerView.isPageCurlTransitioning {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"defer refreshVisibleContent currentPage=\(readerView.currentPage + 1) reason=pageCurlTransition"
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
return
|
||||
}
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
readerView.reloadData()
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContentPreservingLocation currentPage=\(readerView.currentPage + 1) restoreHref=\(restoreLocation?.href ?? "nil") restoreCFI=\(restoreLocation?.cfi ?? "nil")"
|
||||
)
|
||||
if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent using transitionToPage currentPage=\(readerView.currentPage + 1)"
|
||||
)
|
||||
readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent using reloadData currentPage=\(readerView.currentPage + 1)"
|
||||
)
|
||||
readerView.reloadData()
|
||||
}
|
||||
if let restoreLocation {
|
||||
_ = context.restoreReadingLocation(restoreLocation)
|
||||
}
|
||||
@@ -275,6 +237,11 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||
)
|
||||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: runtimeChapter,
|
||||
publication: publication,
|
||||
runtime: runtime
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
@@ -284,13 +251,21 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
|
||||
let partialMap = self.makePartialPageMap(from: initialChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
runtime.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
let cancellationController = self.beginMetadataParseCancellationController(for: token)
|
||||
let worker = RDEPUBMetadataParseWorker(
|
||||
context: context,
|
||||
cancellationController: cancellationController,
|
||||
token: token,
|
||||
parser: parser,
|
||||
publication: publication
|
||||
)
|
||||
worker.start(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
@@ -321,6 +296,55 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
private func loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: RDEPUBRuntimeChapter,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) -> [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
|
||||
)
|
||||
selectedChapters.append(chapter)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"lookahead skip spine=\(spineIndex) reason=\(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -352,524 +376,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
private func waitForReadingInteractionToSettle(
|
||||
using context: RDEPUBReaderContext,
|
||||
cancellationController: MetadataParseCancellationController? = nil
|
||||
) {
|
||||
while context.controller != nil,
|
||||
cancellationController?.isCancelled != true,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
private static let maxRetryCount = 3
|
||||
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
||||
|
||||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return }
|
||||
let cancellationController = beginMetadataParseCancellationController(for: token)
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.finishMetadataParseCancellationController(cancellationController) }
|
||||
guard context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
guard !cancellationController.isCancelled,
|
||||
context.paginationToken == token,
|
||||
context.controller != nil else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm")
|
||||
return
|
||||
}
|
||||
guard let href = publication.spine.indices.contains(spineIndex)
|
||||
? publication.spine[spineIndex].href : nil,
|
||||
let html = parser.htmlString(forRelativePath: href) else {
|
||||
contentHashBySpineIndex[spineIndex] = ""
|
||||
continue
|
||||
}
|
||||
contentHashBySpineIndex[spineIndex] = html.sha256Hex
|
||||
}
|
||||
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
|
||||
|
||||
let catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
let restored = summaryDiskCache?.readAll(keys: catalog)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
|
||||
)
|
||||
let cachedSummaries = restored?.summaries ?? [:]
|
||||
let cachedSpineIndices = Set(cachedSummaries.keys)
|
||||
let resultLock = NSLock()
|
||||
let parseState = MetadataParseState(
|
||||
summariesBySpineIndex: cachedSummaries,
|
||||
totalResolvedCount: cachedSpineIndices.count,
|
||||
lastAppliedCount: cachedSpineIndices.count
|
||||
)
|
||||
|
||||
if !cachedSpineIndices.isEmpty {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
||||
)
|
||||
let cachedMap = self.buildPageMap(from: catalog, 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: allBuildableIndices,
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
cachedSpineIndices: cachedSpineIndices
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
|
||||
)
|
||||
} else {
|
||||
prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
}
|
||||
|
||||
let uncachedSpineIndices = prioritizedSpineIndices
|
||||
|
||||
self.waitForReadingInteractionToSettle(
|
||||
using: context,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
guard !cancellationController.isCancelled,
|
||||
context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
|
||||
return
|
||||
}
|
||||
|
||||
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.rdreader.metadata.parse"
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = workerCount
|
||||
cancellationController.attach(queue: queue)
|
||||
|
||||
let refreshInterval = RDEPUBReaderPaginationCoordinator.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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
|
||||
|
||||
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: layoutConfig)
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
guard let result = try chapterBuilder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
|
||||
return nil
|
||||
}
|
||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||
|
||||
let chapter = result.chapter
|
||||
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: 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 {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
|
||||
return nil
|
||||
}
|
||||
let writeStart = CFAbsoluteTimeGetCurrent()
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
||||
|
||||
timingLock.lock()
|
||||
totalRenderMs += renderElapsed
|
||||
totalWriteMs += writeElapsed
|
||||
completedChapters += 1
|
||||
timingLock.unlock()
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
|
||||
)
|
||||
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
|
||||
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||
|| parseState.totalResolvedCount == allBuildableIndices.count {
|
||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||
snapshot = parseState.summariesBySpineIndex
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if let snapshot {
|
||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
|
||||
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||
timingLock.lock()
|
||||
totalMergeMs += mergeElapsed
|
||||
timingLock.unlock()
|
||||
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("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: 0,
|
||||
token: token,
|
||||
context: context,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig,
|
||||
style: style,
|
||||
renderSignature: renderSignature,
|
||||
summaryDiskCache: summaryDiskCache,
|
||||
contentHashBySpineIndex: contentHashBySpineIndex,
|
||||
resultLock: resultLock,
|
||||
parseState: parseState,
|
||||
allBuildableIndices: allBuildableIndices,
|
||||
catalog: catalog,
|
||||
refreshInterval: refreshInterval,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
}
|
||||
}
|
||||
queue.addOperation(operation)
|
||||
}
|
||||
queue.waitUntilAllOperationsAreFinished()
|
||||
if !cancellationController.isCancelled,
|
||||
context.paginationToken == token,
|
||||
context.controller != nil {
|
||||
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
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
||||
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
)
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = workerCount
|
||||
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||||
return
|
||||
}
|
||||
|
||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
|
||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
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: renderSignature,
|
||||
estimatedMemoryBytes: estimatedBytes
|
||||
)
|
||||
coverageStore.addSegment(segment)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry(
|
||||
spineIndex: Int,
|
||||
retryCount: Int,
|
||||
token: UUID,
|
||||
context: RDEPUBReaderContext,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
renderSignature: String,
|
||||
summaryDiskCache: RDEPUBChapterSummaryDiskCache?,
|
||||
contentHashBySpineIndex: [Int: String],
|
||||
resultLock: NSLock,
|
||||
parseState: MetadataParseState,
|
||||
allBuildableIndices: [Int],
|
||||
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
||||
refreshInterval: Int,
|
||||
cancellationController: MetadataParseCancellationController
|
||||
) {
|
||||
guard retryCount < Self.maxRetryCount else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try chapterBuilder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
|
||||
return
|
||||
}
|
||||
|
||||
let chapter = result.chapter
|
||||
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: 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 else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
|
||||
return
|
||||
}
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
|
||||
resultLock.lock()
|
||||
parseState.summariesBySpineIndex[spineIndex] = summary
|
||||
parseState.totalResolvedCount += 1
|
||||
let shouldRefresh =
|
||||
parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||
|| parseState.totalResolvedCount == allBuildableIndices.count
|
||||
if shouldRefresh {
|
||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if shouldRefresh {
|
||||
let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
|
||||
)
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: retryCount + 1,
|
||||
token: token,
|
||||
context: context,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig,
|
||||
style: style,
|
||||
renderSignature: renderSignature,
|
||||
summaryDiskCache: summaryDiskCache,
|
||||
contentHashBySpineIndex: contentHashBySpineIndex,
|
||||
resultLock: resultLock,
|
||||
parseState: parseState,
|
||||
allBuildableIndices: allBuildableIndices,
|
||||
catalog: catalog,
|
||||
refreshInterval: refreshInterval,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelActiveMetadataParseWork() {
|
||||
metadataParseControlLock.lock()
|
||||
let controller = activeMetadataParseCancellationController
|
||||
@@ -878,17 +390,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller?.cancel()
|
||||
}
|
||||
|
||||
private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController {
|
||||
let controller = MetadataParseCancellationController(token: token)
|
||||
metadataParseControlLock.lock()
|
||||
let previous = activeMetadataParseCancellationController
|
||||
activeMetadataParseCancellationController = controller
|
||||
metadataParseControlLock.unlock()
|
||||
previous?.cancel()
|
||||
return controller
|
||||
}
|
||||
|
||||
private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) {
|
||||
func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
|
||||
metadataParseControlLock.lock()
|
||||
if activeMetadataParseCancellationController === controller {
|
||||
activeMetadataParseCancellationController = nil
|
||||
@@ -896,52 +398,13 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
metadataParseControlLock.unlock()
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
|
||||
let parser = context.parser else {
|
||||
return nil
|
||||
}
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
let href = item.href
|
||||
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
return (
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
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(
|
||||
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ final class RDEPUBReaderRuntime {
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
@@ -349,7 +349,7 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
@@ -605,7 +605,7 @@ final class RDEPUBReaderRuntime {
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
jumpSessionManager.clearSession()
|
||||
backgroundPriorityManager.reset()
|
||||
backgroundCoverageStore.clearAll()
|
||||
|
||||
@@ -45,7 +45,7 @@ final class RDEPUBReaderServices {
|
||||
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (bookIdentifier ?? "default").sha256Hex
|
||||
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
|
||||
let directory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
|
||||
@@ -24,7 +24,7 @@ final class RDEPUBReaderState {
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
|
||||
|
||||
@@ -88,10 +88,10 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
extension RDEPUBReaderTheme {
|
||||
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
contentBackgroundColor.rd_cssString
|
||||
}
|
||||
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
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,92 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
enum RDEPUBDarkImageAdjuster {
|
||||
|
||||
private static let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func adjustIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
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)
|
||||
content.enumerateAttribute(.attachment, in: fullRange) { 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)
|
||||
return adjusted
|
||||
}
|
||||
|
||||
#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,241 @@
|
||||
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)
|
||||
interactionState = state
|
||||
let currentTapSuppressed = interactionState != .idle
|
||||
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,7 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
)
|
||||
}
|
||||
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
enum SelectionInteractionState: Equatable {
|
||||
case idle
|
||||
case selectionPending
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
|
||||
@@ -48,12 +38,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
|
||||
|
||||
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
||||
|
||||
private var selectionInteractionState: SelectionInteractionState = .idle
|
||||
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
@@ -88,12 +74,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
let view = RDEPUBTextPageDecorationView()
|
||||
return view
|
||||
}()
|
||||
|
||||
|
||||
private let overlayView: RDEPUBTextAnnotationOverlay = {
|
||||
let view = RDEPUBTextAnnotationOverlay()
|
||||
return view
|
||||
}()
|
||||
|
||||
|
||||
private let coverImageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
view.contentMode = .scaleAspectFit
|
||||
@@ -123,6 +109,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
gesture.isEnabled = false
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
@@ -133,6 +120,91 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return gesture
|
||||
}()
|
||||
|
||||
private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = {
|
||||
RDEPUBTextContentInteractionCoordinator(
|
||||
dependencies: .init(
|
||||
hasRenderableContent: { [weak self] in
|
||||
self?.hasInteractiveTextContent ?? false
|
||||
},
|
||||
currentSelectionProvider: { [weak self] in self?.currentSelection },
|
||||
isSelectionControllerSelecting: { [weak self] in
|
||||
self?.selectionController.isSelecting ?? false
|
||||
},
|
||||
hasActiveSelection: { [weak self] in
|
||||
self?.selectionController.hasActiveSelection ?? false
|
||||
},
|
||||
selectionHandleAtPoint: { [weak self] point in
|
||||
guard let renderView = self?.coreTextRenderView else { return nil }
|
||||
let handle = renderView.selectionHandle(at: point)
|
||||
switch handle {
|
||||
case .start:
|
||||
return .start
|
||||
case .end:
|
||||
return .end
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
},
|
||||
selectionContainsPoint: { [weak self] point in
|
||||
self?.coreTextRenderView?.selectionContains(point) ?? false
|
||||
},
|
||||
renderPointForGesture: { [weak self] gesture in
|
||||
guard let self else { return .zero }
|
||||
return gesture.location(in: self.coreTextRenderView ?? self)
|
||||
},
|
||||
renderPointForTouch: { [weak self] touch in
|
||||
guard let self else { return .zero }
|
||||
return touch.location(in: self.coreTextRenderView ?? self)
|
||||
},
|
||||
performLongPressSelection: { [weak self] gesture in
|
||||
guard let self else { return }
|
||||
self.layoutIfNeeded()
|
||||
self.selectionController.handleLongPress(
|
||||
gesture,
|
||||
renderView: self.coreTextRenderView,
|
||||
interactionController: self.interactionController
|
||||
)
|
||||
},
|
||||
performPanSelection: { [weak self] gesture in
|
||||
guard let self else { return }
|
||||
self.layoutIfNeeded()
|
||||
self.selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: self.coreTextRenderView,
|
||||
interactionController: self.interactionController
|
||||
)
|
||||
},
|
||||
adjustSelection: { [weak self] handle, point in
|
||||
guard let self else { return }
|
||||
self.selectionController.updateSelection(
|
||||
byAdjusting: handle,
|
||||
at: point,
|
||||
renderView: self.coreTextRenderView,
|
||||
interactionController: self.interactionController
|
||||
)
|
||||
},
|
||||
presentLoupeAtPoint: { [weak self] point in
|
||||
self?.updateSelectionLoupe(for: point)
|
||||
},
|
||||
dismissLoupe: { [weak self] in
|
||||
self?.selectionLoupeView.dismiss()
|
||||
},
|
||||
showSelectionMenu: { [weak self] in
|
||||
self?.showSelectionMenuIfNeeded()
|
||||
},
|
||||
hideSelectionMenu: { [weak self] in
|
||||
self?.hideSelectionMenu()
|
||||
},
|
||||
selectionTapSuppressionDidChange: { [weak self] isSuppressed in
|
||||
self?.selectionTapSuppressionDidChange?(isSuppressed)
|
||||
},
|
||||
selectionPagingSuppressionDidChange: { [weak self] isSuppressed in
|
||||
self?.selectionPagingSuppressionDidChange?(isSuppressed)
|
||||
}
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content.view"
|
||||
@@ -159,11 +231,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
} else {
|
||||
self.hideSelectionMenu()
|
||||
}
|
||||
self.updateSelectionPanAvailability()
|
||||
self.updateViewInteractionAvailability()
|
||||
self.updateAccessibilityDecorationSummary()
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
selectionController.interactionStateDidChange = { [weak self] state in
|
||||
self?.handleSelectionControllerStateChange(state)
|
||||
guard let self else { return }
|
||||
self.interactionCoordinator.selectionControllerStateDidChange(state)
|
||||
self.updateSelectionPanAvailability()
|
||||
self.updateViewInteractionAvailability()
|
||||
}
|
||||
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
||||
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
|
||||
@@ -183,7 +260,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
var isSelectionInteractionInProgress: Bool {
|
||||
selectionInteractionState != .idle
|
||||
interactionCoordinator.isInteractionInProgress
|
||||
}
|
||||
|
||||
var shouldAvoidReaderPageCaching: Bool {
|
||||
currentPage == nil || loadingSpinner.isAnimating
|
||||
}
|
||||
|
||||
private var hasInteractiveTextContent: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
currentPage != nil && coreTextRenderView?.isHidden == false
|
||||
#else
|
||||
currentPage != nil
|
||||
#endif
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
@@ -241,7 +330,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentChapterFragmentOffsets = chapterFragmentOffsets
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
interactionCoordinator.reset()
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
@@ -257,6 +346,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
#endif
|
||||
updateStaticGestureAvailability()
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
@@ -266,7 +358,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
coverImageView.image = nil
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(
|
||||
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||
normalizedPageContent(from: page),
|
||||
configuration: configuration
|
||||
)
|
||||
@@ -301,6 +393,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#endif
|
||||
updateStaticGestureAvailability()
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
updateAccessibilityDecorationSummary()
|
||||
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
@@ -317,8 +412,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentChapterFragmentOffsets = [:]
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
interactionCoordinator.reset()
|
||||
currentHighlights = []
|
||||
currentSearchState = nil
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
@@ -340,6 +434,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
updateStaticGestureAvailability()
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
loadingSpinner.startAnimating()
|
||||
setNeedsLayout()
|
||||
@@ -349,12 +446,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentSearchState = nil
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
selectionLoupeView.dismiss()
|
||||
interactionCoordinator.reset()
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
updateAccessibilityDecorationSummary()
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
@@ -411,87 +508,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func darkImageAdjustedContentIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) -> NSMutableAttributedString {
|
||||
guard configuration.darkImageAdjustmentEnabled,
|
||||
configuration.darkImageBlendRatio > 0,
|
||||
configuration.theme.contentBackgroundColor.rd_isDarkReaderBackground else {
|
||||
return content
|
||||
}
|
||||
|
||||
let fullRange = NSRange(location: 0, length: content.length)
|
||||
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard let attachment = value as? DTImageTextAttachment,
|
||||
!isCoverAttachment(attachment),
|
||||
let image = attachment.image,
|
||||
shouldAdjustDarkImage(image) else { return }
|
||||
|
||||
let adjustedAttachment = DTImageTextAttachment()
|
||||
adjustedAttachment.image = adjustedImage(
|
||||
image,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor,
|
||||
blendRatio: configuration.darkImageBlendRatio,
|
||||
cacheKey: darkImageCacheKey(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 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 func shouldAdjustDarkImage(_ image: UIImage) -> Bool {
|
||||
image.size.width >= 80 && image.size.height >= 80
|
||||
}
|
||||
|
||||
private func darkImageCacheKey(
|
||||
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.ss_cssString)|\(configuration.darkImageBlendRatio)" as NSString
|
||||
}
|
||||
|
||||
private func adjustedImage(
|
||||
_ image: UIImage,
|
||||
backgroundColor: UIColor,
|
||||
blendRatio: CGFloat,
|
||||
cacheKey: NSString
|
||||
) -> UIImage {
|
||||
if let cached = Self.darkAdjustedImageCache.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))
|
||||
}
|
||||
Self.darkAdjustedImageCache.setObject(adjusted, forKey: cacheKey)
|
||||
return adjusted
|
||||
}
|
||||
#endif
|
||||
|
||||
private func applyHighlightsToContent(
|
||||
_ content: NSMutableAttributedString,
|
||||
highlights: [RDEPUBHighlight],
|
||||
@@ -581,80 +597,38 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#endif
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
layoutIfNeeded()
|
||||
selectionController.handleLongPress(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.selecting)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .changed:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .ended:
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
activeSelectionHandle = nil
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
interactionCoordinator.handleLongPress(gesture)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
layoutIfNeeded()
|
||||
let point = gesture.location(in: coreTextRenderView ?? self)
|
||||
if gesture.state == .began, activeSelectionHandle == nil {
|
||||
if let renderView = coreTextRenderView,
|
||||
let handle = renderView.selectionHandle(at: point) {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
interactionCoordinator.handlePan(gesture)
|
||||
}
|
||||
|
||||
if let activeSelectionHandle, let renderView = coreTextRenderView {
|
||||
selectionController.updateSelection(
|
||||
byAdjusting: activeSelectionHandle,
|
||||
at: point,
|
||||
renderView: renderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
updateSelectionLoupe(for: point)
|
||||
} else {
|
||||
selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
if selectionController.isSelecting {
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
private func updateSelectionPanAvailability() {
|
||||
let shouldEnablePan = selectionController.isSelecting
|
||||
|| selectionController.hasActiveSelection
|
||||
|| interactionCoordinator.interactionState == .adjustingHandle
|
||||
guard panGestureRecognizer.isEnabled != shouldEnablePan else { return }
|
||||
panGestureRecognizer.isEnabled = shouldEnablePan
|
||||
}
|
||||
|
||||
private func updateViewInteractionAvailability() {
|
||||
let shouldEnableInteraction = hasInteractiveTextContent
|
||||
|| selectionController.isSelecting
|
||||
|| selectionController.hasActiveSelection
|
||||
|| interactionCoordinator.interactionState != .idle
|
||||
guard isUserInteractionEnabled != shouldEnableInteraction else { return }
|
||||
isUserInteractionEnabled = shouldEnableInteraction
|
||||
}
|
||||
|
||||
private func updateStaticGestureAvailability() {
|
||||
let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false
|
||||
let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false
|
||||
let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress
|
||||
let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap
|
||||
guard longPressChanged || tapChanged else { return }
|
||||
longPressGestureRecognizer.isEnabled = shouldEnableLongPress
|
||||
tapGestureRecognizer.isEnabled = shouldEnableTap
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
@@ -728,44 +702,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
|
||||
switch state {
|
||||
case .idle:
|
||||
if currentSelection == nil, activeSelectionHandle == nil {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
case .selecting:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
case .selectionActive:
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
case .adjustingHandle:
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
|
||||
let previousTapSuppressed = selectionInteractionState != .idle
|
||||
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
selectionInteractionState = state
|
||||
let currentTapSuppressed = selectionInteractionState != .idle
|
||||
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
if previousTapSuppressed != currentTapSuppressed {
|
||||
selectionTapSuppressionDidChange?(currentTapSuppressed)
|
||||
}
|
||||
if previousPagingSuppressed != currentPagingSuppressed {
|
||||
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
|
||||
switch state {
|
||||
case .idle, .selectionPending, .selectionActive:
|
||||
return false
|
||||
case .selecting, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var coreTextRenderView: RDEPUBTextPageRenderView? {
|
||||
#if canImport(DTCoreText)
|
||||
return coreTextContentView
|
||||
@@ -784,7 +720,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||
guard let page = currentPage else { return nil }
|
||||
guard currentPage != nil else { return nil }
|
||||
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
|
||||
guard let absoluteRange else { return nil }
|
||||
let matches = currentHighlights.filter { highlight in
|
||||
@@ -857,7 +793,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
if selectionInteractionState == .selectionPending {
|
||||
if interactionCoordinator.interactionState == .selectionPending {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -871,7 +807,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
||||
return true
|
||||
}
|
||||
switch selectionInteractionState {
|
||||
switch interactionCoordinator.interactionState {
|
||||
case .idle:
|
||||
return false
|
||||
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
||||
@@ -884,65 +820,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
#if canImport(DTCoreText)
|
||||
guard selectionInteractionState == .idle,
|
||||
activeSelectionHandle == nil,
|
||||
currentPage != nil,
|
||||
let touch = touches.first,
|
||||
let renderView = coreTextRenderView else {
|
||||
return
|
||||
}
|
||||
let point = touch.location(in: renderView)
|
||||
guard renderView.selectionHandle(at: point) == nil else { return }
|
||||
updateSelectionInteractionState(.selectionPending)
|
||||
#endif
|
||||
interactionCoordinator.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
interactionCoordinator.touchesEnded(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
private func resetSelectionPendingIfNeeded() {
|
||||
guard selectionInteractionState == .selectionPending else { return }
|
||||
if currentSelection != nil {
|
||||
updateSelectionInteractionState(.selectionActive)
|
||||
} else {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
interactionCoordinator.touchesCancelled(touches, with: event)
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
if selectionController.isSelecting {
|
||||
return true
|
||||
}
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return false
|
||||
}
|
||||
let point = pan.location(in: renderView)
|
||||
return renderView.selectionHandle(at: point) != nil
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer {
|
||||
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
let point = longPress.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === tapGestureRecognizer {
|
||||
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
|
||||
return false
|
||||
@@ -962,123 +853,35 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
return currentPage != nil
|
||||
}
|
||||
return true
|
||||
return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldReceive touch: UITouch
|
||||
) -> Bool {
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
|
||||
let point = touch.location(in: renderView)
|
||||
if let handle = renderView.selectionHandle(at: point) {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
if gestureRecognizer === tapGestureRecognizer {
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
|
||||
|
||||
let point = touch.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBSelectionLoupeView: UIView {
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
private let magnification: CGFloat = 1.45
|
||||
|
||||
private let captureSize = CGSize(width: 84, height: 84)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
|
||||
isUserInteractionEnabled = false
|
||||
backgroundColor = .clear
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.18
|
||||
layer.shadowRadius = 10
|
||||
layer.shadowOffset = CGSize(width: 0, height: 5)
|
||||
|
||||
imageView.frame = bounds
|
||||
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
imageView.layer.cornerRadius = bounds.width / 2
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.layer.borderWidth = 1.5
|
||||
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
|
||||
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
|
||||
|
||||
let targetCenter = CGPoint(
|
||||
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
|
||||
y: min(
|
||||
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
|
||||
hostBounds.maxY - bounds.height / 2
|
||||
gestureRecognizer === tapGestureRecognizer
|
||||
|| interactionCoordinator.gestureRecognizer(
|
||||
gestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
|
||||
)
|
||||
)
|
||||
|
||||
center = targetCenter
|
||||
if isHidden {
|
||||
alpha = 0
|
||||
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
|
||||
isHidden = false
|
||||
UIView.animate(withDuration: 0.12) {
|
||||
self.alpha = 1
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
guard !isHidden else { return }
|
||||
isHidden = true
|
||||
alpha = 0
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: captureSize)
|
||||
return renderer.image { context in
|
||||
let cgContext = context.cgContext
|
||||
cgContext.setFillColor(UIColor.systemBackground.cgColor)
|
||||
cgContext.fill(CGRect(origin: .zero, size: captureSize))
|
||||
cgContext.translateBy(
|
||||
x: captureSize.width / 2 - focusPoint.x * magnification,
|
||||
y: captureSize.height / 2 - focusPoint.y * magnification
|
||||
)
|
||||
cgContext.scaleBy(x: magnification, y: magnification)
|
||||
sourceView.layer.render(in: cgContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_isDarkReaderBackground: Bool {
|
||||
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
|
||||
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol RDReaderCachePolicyProviding {
|
||||
var shouldAvoidReaderPageCaching: Bool { get }
|
||||
}
|
||||
|
||||
final class RDReaderPreloadController {
|
||||
|
||||
var radius: Int = 2
|
||||
@@ -76,7 +80,11 @@ final class RDReaderPreloadController {
|
||||
) -> UIView {
|
||||
let reusableView = detachedReusablePageView(for: pageNum)
|
||||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
if shouldCache(view: view, for: pageNum, environment: environment) {
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
} else {
|
||||
pageCurlCachedViews.removeValue(forKey: pageNum)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -105,12 +113,20 @@ final class RDReaderPreloadController {
|
||||
for targetPage in targets {
|
||||
let existing = detachedReusablePageView(for: targetPage)
|
||||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||
if shouldCacheContentView {
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
} else {
|
||||
preloadedPageViews.removeValue(forKey: targetPage)
|
||||
}
|
||||
if contentView.superview !== preloadHostView {
|
||||
contentView.removeFromSuperview()
|
||||
preloadHostView.addSubview(contentView)
|
||||
}
|
||||
contentView.frame = preloadHostView.bounds
|
||||
if !shouldCacheContentView {
|
||||
contentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +155,14 @@ final class RDReaderPreloadController {
|
||||
&& pageNum < environment.totalPages
|
||||
}
|
||||
|
||||
private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return false }
|
||||
if let policyProvider = view as? RDReaderCachePolicyProviding {
|
||||
return !policyProvider.shouldAvoidReaderPageCaching
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func isDualPage(environment: Environment) -> Bool {
|
||||
environment.landscapeDualPageEnabled
|
||||
&& environment.isLandscape
|
||||
|
||||
@@ -96,6 +96,11 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
let resolvedPage = (pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.pageNum ?? -1
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PageCurl",
|
||||
"didFinishAnimating finished=\(finished) completed=\(completed) resolvedPage=\(resolvedPage + 1) currentPageBeforeFinish=\(currentPage + 1)"
|
||||
)
|
||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
|
||||
let pn = firstVC.pageNum
|
||||
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
|
||||
@@ -114,8 +119,17 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
|
||||
target.pageNum != RDReaderView.blankPageNum,
|
||||
target.pageNum != RDReaderView.blankEndPageNum {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PageCurl",
|
||||
"willTransitionTo targetPage=\(target.pageNum + 1) currentPage=\(currentPage + 1)"
|
||||
)
|
||||
predictedPageDirection = target.pageNum >= currentPage
|
||||
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PageCurl",
|
||||
"willTransitionTo blank target currentPage=\(currentPage + 1)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,10 @@ public class RDReaderView: UIView {
|
||||
set { pagingController.pendingTransitionRequest = newValue }
|
||||
}
|
||||
|
||||
var isPageCurlTransitioning: Bool {
|
||||
currentDisplayType == .pageCurl && pagingController.isTransitioning
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content"
|
||||
@@ -401,6 +405,8 @@ public class RDReaderView: UIView {
|
||||
if let pending = pagingController.finishPageCurlTransition() {
|
||||
transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
|
||||
}
|
||||
|
||||
updatePagingInteractionSuppression()
|
||||
}
|
||||
|
||||
private var preloadEnvironment: RDReaderPreloadController.Environment {
|
||||
@@ -603,12 +609,24 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
|
||||
private func updatePagingInteractionSuppression() {
|
||||
let shouldSuppress = selectionPagingSuppressedContentViews.allObjects.isEmpty == false
|
||||
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
|
||||
guard shouldSuppress != isPagingInteractionSuppressed else { return }
|
||||
isPagingInteractionSuppressed = shouldSuppress
|
||||
setPagingInteractionEnabled(!shouldSuppress)
|
||||
}
|
||||
|
||||
private func activePagingSuppressionContentViews() -> [UIView] {
|
||||
selectionPagingSuppressedContentViews.allObjects.filter { contentView in
|
||||
guard contentView.window != nil,
|
||||
contentView.isDescendant(of: self),
|
||||
contentView.bounds.isEmpty == false else {
|
||||
return false
|
||||
}
|
||||
let frameInReader = contentView.convert(contentView.bounds, to: self)
|
||||
return frameInReader.intersects(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
|
||||
collectionView.isScrollEnabled = isEnabled
|
||||
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
|
||||
@@ -721,7 +739,13 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
|
||||
public func reloadPageCountOnly() {
|
||||
collectionView.reloadData()
|
||||
if currentDisplayType == .pageCurl {
|
||||
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
|
||||
transitionToPage(pageNum: currentPage, animated: false)
|
||||
}
|
||||
} else {
|
||||
collectionView.reloadData()
|
||||
}
|
||||
topToolView = resolvedTopChromeView()
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user