feat: chapter runtime refactoring and related updates

- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-26 18:50:07 +09:00
co-authored by Claude
parent b8aa10c535
commit 22e7e44220
123 changed files with 3701 additions and 9118 deletions
@@ -6,7 +6,20 @@ import DTCoreText
enum RDEPUBDarkImageAdjuster {
private static let imageCache = NSCache<NSString, UIImage>()
private static let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 52_428_800 // 50 MB
return cache
}()
private static func imageCost(of image: UIImage) -> Int {
let scale = image.scale
let width = Int(image.size.width * scale)
let height = Int(image.size.height * scale)
// 4 bytes per pixel (RGBA)
return width * height * 4
}
#if canImport(DTCoreText)
@@ -84,7 +97,7 @@ enum RDEPUBDarkImageAdjuster {
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
imageCache.setObject(adjusted, forKey: cacheKey)
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
return adjusted
}
@@ -46,6 +46,10 @@ extension RDEPUBTextContentViewDelegate {
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
private static let pageNumberTrailingPadding: CGFloat = 4
private static let pageNumberFooterPadding: CGFloat = 8
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
@@ -56,6 +60,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
private var menuSelection: RDEPUBSelection?
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
// Stored as Any? to avoid @available on stored property restriction.
private var _editMenuInteraction: Any?
private let selectionLoupeView = RDEPUBSelectionLoupeView()
private var currentHighlights: [RDEPUBHighlight] = []
@@ -241,6 +249,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
selectionLoupeView.isHidden = true
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
if #available(iOS 16.0, *) {
let interaction = UIEditMenuInteraction(delegate: self)
addInteraction(interaction)
self._editMenuInteraction = interaction
}
selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return }
self.currentSelection = selection
@@ -322,10 +337,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
overlayView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
let contentRect = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
)
let footerOriginY = bounds.maxY - contentInsets.bottom
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
y: footerOriginY + footerVerticalInset,
width: labelSize.width,
height: labelSize.height
)
@@ -352,7 +372,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
@@ -434,7 +457,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
@@ -471,7 +497,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
hideSelectionMenu()
}
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
@@ -562,25 +588,47 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
guard content.length > 0 else { return content }
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else { return content }
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
if shouldNormalizeLeadingParagraphSpacing(for: page) {
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
updateParagraphStyle(in: content, range: leadingRange) { style in
style.paragraphSpacingBefore = 0
}
}
guard shouldNormalizeContinuationParagraph(for: page),
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
style.firstLineHeadIndent = style.headIndent
style.paragraphSpacingBefore = 0
}
return content
}
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
page.pageStartOffset == 0
}
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
let text = content.string as NSString
var index = 0
while index < text.length {
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
return text.paragraphRange(for: NSRange(location: index, length: 0))
}
index += 1
}
return nil
}
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
@@ -589,6 +637,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return !CharacterSet.newlines.contains(previousScalar)
}
private func updateParagraphStyle(
in content: NSMutableAttributedString,
range: NSRange,
transform: (NSMutableParagraphStyle) -> Void
) {
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
transform(mutableStyle)
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
}
}
private func firstParagraphUsesNonLeadingAlignment(
in content: NSAttributedString,
range: NSRange
@@ -743,19 +805,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
!targetRect.isEmpty else {
return
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
interaction.presentEditMenu(with: config)
} else {
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
}
}
private func hideSelectionMenu() {
UIMenuController.shared.setMenuVisible(false, animated: true)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
interaction.dismissMenu()
} else {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
}
private func updateSelectionLoupe(for point: CGPoint) {
@@ -954,3 +1026,51 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
)
}
}
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
@available(iOS 16.0, *)
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration
) -> UIMenu {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
])
}
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
targetRectFor configuration: UIEditMenuConfiguration
) -> CGRect {
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty {
return targetRect
}
return bounds
}
}
// MARK: - Safe Content Insets
extension RDEPUBTextContentView {
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
private static func safeContentInsets(
configuration: RDEPUBReaderConfiguration,
safeAreaInsets: UIEdgeInsets
) -> UIEdgeInsets {
let configInsets = configuration.reflowableContentInsets
return UIEdgeInsets(
top: max(configInsets.top, safeAreaInsets.top),
left: max(configInsets.left, safeAreaInsets.left),
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
right: max(configInsets.right, safeAreaInsets.right)
)
}
}
@@ -148,7 +148,6 @@ final class RDEPUBTextSelectionController: NSObject {
selectionEndIndex = NSNotFound
setInteractionState(.idle)
renderView?.selectionRects = []
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}