ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shenlei 5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00

1196 lines
49 KiB
Swift

import UIKit
import Foundation
#if canImport(DTCoreText)
import DTCoreText
#endif
protocol RDEPUBTextContentViewDelegate: AnyObject {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect,
sourcePoint: CGPoint
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
)
}
extension RDEPUBTextContentViewDelegate {
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
) {
// Default: no-op. Implementors can present an image viewer.
}
}
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?
private var currentChapterCFIMap: RDEPUBCFIMap?
private var currentChapterFragmentOffsets: [String: Int] = [:]
private var currentSelection: RDEPUBSelection?
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] = []
private var currentSearchState: RDEPUBSearchState?
weak var delegate: RDEPUBTextContentViewDelegate?
var selectionTapSuppressionDidChange: ((Bool) -> Void)?
var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
#if canImport(DTCoreText)
private let coreTextContentView: RDEPUBTextPageRenderView = {
let view = RDEPUBTextPageRenderView()
view.backgroundColor = .clear
view.isOpaque = false
view.isAccessibilityElement = true
view.accessibilityTraits = .staticText
return view
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
/// Framesetter over the full chapter copy, rebuilt when the display
/// content changes; layout frames are recomputed per bounds change.
private var coreTextLayouter: DTCoreTextLayouter?
#endif
private let interactionController = RDEPUBPageInteractionController()
private let selectionController = RDEPUBTextSelectionController()
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
let view = RDEPUBTextPageDecorationView()
return view
}()
private let overlayView: RDEPUBTextAnnotationOverlay = {
let view = RDEPUBTextAnnotationOverlay()
return view
}()
private let coverImageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.isHidden = true
return view
}()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
private let loadingSpinner: UIActivityIndicatorView = {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.hidesWhenStopped = true
spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
return spinner
}()
#if DEBUG
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
private let debugContentAreaBorderView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
view.layer.borderWidth = 1
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
return view
}()
#endif
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.5
gesture.delegate = self
return gesture
}()
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.isEnabled = false
gesture.delegate = self
return gesture
}()
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.delegate = self
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"
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
addSubview(coreTextContentView)
#endif
addSubview(overlayView)
addSubview(pageNumberLabel)
addSubview(loadingSpinner)
addSubview(selectionLoupeView)
#if DEBUG
addSubview(debugContentAreaBorderView)
#endif
addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer)
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
if let selection {
self.menuSelection = selection
} else {
self.hideSelectionMenu()
}
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.interactionStateDidChange = { [weak self] state in
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 }
selectionController.chapterFragmentOffsetsProvider = { [weak self] in
self?.currentChapterFragmentOffsets ?? [:]
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFirstResponder: Bool { true }
var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
longPressGestureRecognizer
}
var isSelectionInteractionInProgress: Bool {
interactionCoordinator.isInteractionInProgress
}
var shouldAvoidReaderPageCaching: Bool {
#if canImport(DTCoreText)
// In-context text layout keeps a chapter-length attributed copy and
// layouter alive for the visible page. Avoid reader-level page
// caching so preloading does not retain several full-chapter
// display copies at once.
return currentPage == nil
|| loadingSpinner.isAnimating
|| coreTextDisplayContent != nil
#else
currentPage == nil
|| loadingSpinner.isAnimating
#endif
}
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 {
action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
|| action == #selector(rd_annotate(_:))
}
@objc func rd_copy(_ sender: Any?) {
performSelectionAction(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
performSelectionAction(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
performSelectionAction(.annotate)
}
override func layoutSubviews() {
super.layoutSubviews()
#if canImport(DTCoreText)
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
coreTextContentView.frame = bounds.inset(by: contentInsets)
updateCoreTextLayoutFrameIfNeeded()
#endif
overlayView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
let contentRect = bounds.inset(by: contentInsets)
#if DEBUG
debugContentAreaBorderView.frame = contentRect
#endif
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.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
y: footerOriginY + footerVerticalInset,
width: labelSize.width,
height: labelSize.height
)
loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
chapterCFIMap: RDEPUBCFIMap? = nil,
chapterFragmentOffsets: [String: Int] = [:],
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
loadingSpinner.stopAnimating()
currentPage = page
currentChapterCFIMap = chapterCFIMap
currentChapterFragmentOffsets = chapterFragmentOffsets
currentSelection = nil
menuSelection = nil
interactionCoordinator.reset()
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
// In-context display: keep the full chapter string and lay out only the
// page's range. CTTypesetter line breaks are context-sensitive, so
// re-wrapping a page substring can break ±1 character away from the
// paginator's line ends (lone characters spilling onto the last line);
// laying out the same chapter string the paginator used cannot.
// String indices in the layout frame are therefore chapter-absolute.
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
let pageRange = NSIntersectionRange(
page.contentRange,
NSRange(location: 0, length: displayContent.length)
)
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
displayContent,
in: pageRange,
configuration: configuration
)
displayContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: pageRange
)
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
coreTextDisplayContent = displayContent
coreTextDisplayRange = pageRange
coreTextLayouter = DTCoreTextLayouter(attributedString: displayContent)
coreTextLayouter?.shouldCacheLayoutFrames = false
coreTextContentView.attributedDisplayContent = displayContent
updateCoreTextLayoutFrameIfNeeded()
#else
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
#endif
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func configureLoading(
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration
) {
currentPage = nil
currentChapterCFIMap = nil
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
interactionCoordinator.reset()
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextContentView.attributedDisplayContent = nil
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
}
func clearSelection() {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
interactionCoordinator.reset()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
hideSelectionMenu()
}
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
let selection = currentSelection ?? menuSelection
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
menuSelection = nil
hideSelectionMenu()
}
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0,
page.href.lowercased().contains("cover"),
let image = coverImage(from: page.content) else {
return false
}
coverImageView.image = image
coverImageView.isHidden = false
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
return true
}
private func coverImage(from content: NSAttributedString) -> UIImage? {
guard content.length > 0 else { return nil }
var resolvedImage: UIImage?
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard let image = image(from: value) else { return }
resolvedImage = image
stop.pointee = true
}
return resolvedImage
}
private func image(from attachmentValue: Any?) -> UIImage? {
#if canImport(DTCoreText)
if let attachment = attachmentValue as? DTTextAttachment,
let url = attachment.contentURL {
return UIImage(contentsOfFile: url.path)
}
#endif
if let attachment = attachmentValue as? NSTextAttachment {
if let image = attachment.image { return image }
if let data = attachment.contents { return UIImage(data: data) }
if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents { return UIImage(data: data) }
}
return nil
}
private func imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? {
guard page.chapterContent.length > attachment.stringRange.location else { return nil }
let attachmentValue = page.chapterContent.attribute(
.attachment,
at: attachment.stringRange.location,
effectiveRange: nil
)
return image(from: attachmentValue)
}
/// Marks highlight ranges on the chapter-length display copy. Ranges stay
/// chapter-absolute the in-context layout frame uses the same indices.
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
page: RDEPUBTextPage
) {
for highlight in highlights {
guard highlight.location.href == page.href else { continue }
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
let absoluteRange = info.nsRange else { continue }
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
guard overlap.length > 0,
overlap.location + overlap.length <= content.length else { continue }
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: overlap)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: overlap)
}
}
}
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.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 }
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
}
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 }
let chapterText = page.chapterContent.string as NSString
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
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
) -> Bool {
var usesNonLeadingAlignment = false
content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
guard let style = value as? NSParagraphStyle else { return }
if style.alignment == .right || style.alignment == .center {
usesNonLeadingAlignment = true
stop.pointee = true
}
}
return usesNonLeadingAlignment
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden,
let displayContent = coreTextDisplayContent,
let displayRange = coreTextDisplayRange,
let page = currentPage,
coreTextContentView.bounds.width > 0,
coreTextContentView.bounds.height > 0 else {
interactionController.configure(layoutFrame: nil, page: currentPage)
return
}
guard let layouter = coreTextLayouter else {
coreTextContentView.layoutFrame = nil
interactionController.configure(layoutFrame: nil, page: page)
return
}
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
if let layoutFrame {
RDEPUBTextPageBoundaryValidator.validate(
page: page,
displayLayoutFrame: layoutFrame,
displayContent: displayContent,
displayBounds: coreTextContentView.bounds
)
RDEPUBTextPageVerticalJustifier.justify(
layoutFrame,
contentHeight: coreTextContentView.bounds.height,
isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
pixelScale: window?.screen.scale ?? UIScreen.main.scale
)
}
coreTextContentView.layoutFrame = layoutFrame
interactionController.configure(layoutFrame: layoutFrame, page: page)
overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
if let page = currentPage {
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
// Text-page highlights/underlines are already painted by the
// CoreText render view via display-content attributes. Keep
// overlay decorations for search only to avoid double drawing.
highlights: [],
searchState: currentSearchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
}
}
#endif
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
interactionCoordinator.handleLongPress(gesture)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
interactionCoordinator.handlePan(gesture)
}
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
RDReaderTapDebug.log(
"TextContentView.interactionAvailability",
"updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)"
)
}
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
RDReaderTapDebug.log(
"TextContentView.gestureAvailability",
"updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)"
)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
RDReaderTapDebug.log(
"TextContentView.handleTap",
"received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)"
)
if let renderView = coreTextRenderView {
let renderPoint = gesture.location(in: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle")
return
}
if currentSelection != nil {
if renderView.selectionContains(renderPoint) {
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu")
showSelectionMenuIfNeeded()
return
}
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection")
clearSelection()
return
}
} else if currentSelection != nil {
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection")
clearSelection()
return
}
// PRIORITY: Check for attachment tap
if let page = currentPage,
let attachment = interactionController.snapshot?.attachment(at: point) {
let kind = attachment.kind
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero
// Footnote attachments carry their note text in alt/accessibility metadata.
// Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap")
delegate?.textContentView(
self,
didActivateAttachmentText: footnoteText,
sourceRect: sourceRect,
sourcePoint: convert(point, from: overlayView)
)
return
}
// Regular image attachments: present image viewer
if let image = imageFromPage(attachment: attachment, page: page) {
let altText = attachmentText(at: point)
RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)")
delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
return
}
}
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap")
delegate?.textContentView(
self,
didActivateAttachmentText: attachmentText,
sourceRect: sourceRect,
sourcePoint: convert(point, from: overlayView)
)
return
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
return
}
RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
}
private func showSelectionMenuIfNeeded() {
guard currentSelection != nil,
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty else {
return
}
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
becomeFirstResponder()
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
DispatchQueue.main.async { [weak self, weak interaction] in
guard let self, self.currentSelection != nil else { return }
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(resolvedTargetRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
}
private func hideSelectionMenu() {
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) {
#if canImport(DTCoreText)
guard let renderView = coreTextRenderView else { return }
let localPoint = convert(point, from: renderView)
selectionLoupeView.present(
sourceView: renderView,
focusPoint: point,
hostBounds: bounds.inset(by: contentInsets),
targetPoint: localPoint
)
#endif
}
private var coreTextRenderView: RDEPUBTextPageRenderView? {
#if canImport(DTCoreText)
return coreTextContentView
#else
return nil
#endif
}
private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
guard let renderView = coreTextRenderView else { return rect }
return convert(rect, from: renderView)
}
private func updateAccessibilityDecorationSummary() {
#if canImport(DTCoreText)
coreTextContentView.accessibilityValue = [
backgroundOverlayView.decorationSummary(),
overlayView.decorationSummary()
].joined(separator: " | ")
#endif
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
let overlap = NSIntersectionRange(range, page.contentRange)
guard overlap.length > 0 else { return false }
return interactionController.selectionRects(for: overlap).contains {
$0.insetBy(dx: -4, dy: -4).contains(point)
}
}
return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
if lhs.hasNote != rhs.hasNote {
return lhs.hasNote && !rhs.hasNote
}
return lhsRange < rhsRange
}.first
}
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
let rects = interactionController.selectionRects(for: range)
if let first = rects.first {
var unionRect = first
for rect in rects.dropFirst() {
unionRect = unionRect.union(rect)
}
return overlayView.convert(unionRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
private func attachmentText(at point: CGPoint) -> String? {
guard let page = currentPage else { return nil }
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
return nil
}
guard page.chapterContent.length > attachmentRange.location else {
return nil
}
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment,
let altText = (textAttachment.attributes["alt"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
#endif
if let fileAttachment = attachment as? NSTextAttachment,
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
return nil
}
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
return overlayView.convert(attachmentRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
#if canImport(DTCoreText)
if interactionCoordinator.interactionState == .selectionPending {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending")
return true
}
guard let renderView = coreTextRenderView else {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil")
return false
}
let renderPoint = convert(point, to: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle")
return true
}
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection")
return true
}
switch interactionCoordinator.interactionState {
case .idle:
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle")
return false
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
RDReaderTapDebug.log(
"TextContentView.shouldSuppressReaderTap",
"return true because interactionState=\(interactionCoordinator.interactionState)"
)
return true
}
#else
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable")
return false
#endif
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
interactionCoordinator.touchesBegan(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
interactionCoordinator.touchesEnded(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
interactionCoordinator.touchesCancelled(touches, with: event)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
}
if selectionController.hasActiveSelection {
if let renderView = coreTextRenderView {
let point = tapGestureRecognizer.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
}
return true
}
let point = tapGestureRecognizer.location(in: overlayView)
if attachmentText(at: point) != nil || highlight(at: point) != nil {
return true
}
return currentPage != nil
}
return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
if gestureRecognizer === tapGestureRecognizer {
guard let renderView = coreTextRenderView else {
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil")
return true
}
let point = touch.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle")
return false
}
}
let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
RDReaderTapDebug.log(
"TextContentView.gestureShouldReceive",
"gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)"
)
return result
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === tapGestureRecognizer
|| interactionCoordinator.gestureRecognizer(
gestureRecognizer,
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
)
}
}
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
@available(iOS 16.0, *)
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> 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 resolvedSelectionMenuTargetRect(from: 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)
)
}
}