import UIKit import Foundation #if canImport(DTCoreText) import DTCoreText #endif // MARK: - 文本内容视图代理 /// 文本内容视图的代理协议 /// 通知控制器文本选择变化和选择菜单操作 protocol RDEPUBTextContentViewDelegate: AnyObject { /// 用户选中文本发生变化时调用 func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) /// 用户从选择菜单中触发操作(拷贝/高亮/批注) func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) } // MARK: - 可选中文本视图 /// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注) final class RDEPUBSelectableTextView: UITextView { /// 选择菜单操作回调 var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { switch action { case #selector(rd_copy(_:)), #selector(rd_highlight(_:)), #selector(rd_annotate(_:)): return selectedRange.location != NSNotFound && selectedRange.length > 0 default: return false } } @objc func rd_copy(_ sender: Any?) { onSelectionAction?(.copy) } @objc func rd_highlight(_ sender: Any?) { onSelectionAction?(.highlight) } @objc func rd_annotate(_ sender: Any?) { onSelectionAction?(.annotate) } } // MARK: - DTCoreText 直接绘制视图 /// 基于 DTCoreText 的 Core Text 直接绘制视图 /// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染 #if canImport(DTCoreText) final class RDEPUBDirectCoreTextPageView: UIView { var layoutFrame: DTCoreTextLayoutFrame? { didSet { setNeedsDisplay() } } var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false contentMode = .redraw } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return } context.saveGState() layoutFrame.draw(in: context, options: drawOptions) context.restoreGState() } } #endif // MARK: - 文本内容视图 /// EPUB 流式排版的文本内容视图 /// 支持两种渲染路径: /// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制 /// 2. 回退路径:通过 UITextView 的 attributedText 渲染 /// /// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示 final class RDEPUBTextContentView: UIView { private var contentInsets: UIEdgeInsets = .zero private var currentPage: RDEPUBTextPage? private var highlightedRanges: [RDEPUBHighlight] = [] private var currentSearchState: RDEPUBSearchState? private var isSelectionFromInteraction = false private var selectionMenuAnchorRect: CGRect? weak var delegate: RDEPUBTextContentViewDelegate? #if canImport(DTCoreText) private let coreTextContentView: RDEPUBDirectCoreTextPageView = { let view = RDEPUBDirectCoreTextPageView() view.backgroundColor = .clear view.isOpaque = false return view }() private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayRange: NSRange? #endif private let interactionController = RDEPUBPageInteractionController() private let backgroundOverlayView: RDEPUBSelectionOverlayView = { let view = RDEPUBSelectionOverlayView() return view }() private let overlayView: RDEPUBSelectionOverlayView = { let view = RDEPUBSelectionOverlayView() return view }() private var selectionAnchorPoint: CGPoint? private let textView: RDEPUBSelectableTextView = { let view = RDEPUBSelectableTextView() view.isEditable = false view.isScrollEnabled = false view.isSelectable = true view.backgroundColor = .clear view.textContainerInset = .zero view.textContainer.lineFragmentPadding = 0 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 }() override init(frame: CGRect) { super.init(frame: frame) addSubview(coverImageView) #if canImport(DTCoreText) addSubview(backgroundOverlayView) addSubview(coreTextContentView) #endif addSubview(overlayView) addSubview(textView) addSubview(pageNumberLabel) textView.delegate = self textView.onSelectionAction = { [weak self] action in guard let self else { return } self.delegate?.textContentView(self, didRequestSelectionAction: action) } let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) longPress.minimumPressDuration = 0.4 addGestureRecognizer(longPress) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTapsRequired = 1 addGestureRecognizer(tap) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var canBecomeFirstResponder: Bool { true } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { #if canImport(DTCoreText) switch action { case #selector(rd_copy(_:)), #selector(rd_highlight(_:)), #selector(rd_annotate(_:)): return overlayView.selectionRange?.length ?? 0 > 0 default: return false } #else return super.canPerformAction(action, withSender: sender) #endif } 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) textView.frame = bounds.inset(by: contentInsets) coverImageView.frame = bounds.inset(by: contentInsets) let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) pageNumberLabel.frame = CGRect( x: bounds.width - labelSize.width - 24, y: bounds.height - labelSize.height - 20, width: labelSize.width, height: labelSize.height ) } func configure( page: RDEPUBTextPage, pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration, highlights: [RDEPUBHighlight] = [], searchState: RDEPUBSearchState? = nil ) { currentPage = page highlightedRanges = highlights currentSearchState = searchState contentInsets = configuration.reflowableContentInsets 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 textView.isHidden = true textView.isUserInteractionEnabled = false #endif textView.attributedText = nil textView.selectedRange = NSRange(location: 0, length: 0) delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() return } coverImageView.isHidden = true coverImageView.image = nil let selectionContent = normalizedPageContent(from: page) let selectionRange = NSRange(location: 0, length: selectionContent.length) selectionContent.addAttribute( .foregroundColor, value: configuration.theme.contentTextColor, range: selectionRange ) #if canImport(DTCoreText) let displayContent = normalizedPageContent(from: page) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( .foregroundColor, value: configuration.theme.contentTextColor, range: fullRange ) coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextDisplayContent = displayContent coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) textView.isHidden = true textView.isUserInteractionEnabled = false textView.attributedText = nil updateCoreTextLayoutFrameIfNeeded() #else applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset) applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset) textView.isHidden = false textView.isUserInteractionEnabled = true #endif #if !canImport(DTCoreText) textView.tintColor = configuration.theme.toolControlTextColor textView.attributedText = selectionProxyContent(from: selectionContent) textView.selectedRange = NSRange(location: 0, length: 0) #endif overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) #if canImport(DTCoreText) backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) let (bgDecorations, fgDecorations) = buildOverlayDecorations(page: page) backgroundOverlayView.applyDecorations(bgDecorations) overlayView.applyDecorations(fgDecorations) #endif delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() } func clearSelection() { textView.selectedRange = NSRange(location: 0, length: 0) overlayView.clearSelection() backgroundOverlayView.clearSelection() selectionAnchorPoint = nil selectionMenuAnchorRect = nil isSelectionFromInteraction = false UIMenuController.shared.setMenuVisible(false, animated: true) delegate?.textContentView(self, didChangeSelection: nil) } // MARK: - Gesture Handling @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { let point = gesture.location(in: overlayView) switch gesture.state { case .began: selectionAnchorPoint = point isSelectionFromInteraction = true handleSelectionFromInteraction(point: point, anchorPoint: nil) case .changed: guard let anchor = selectionAnchorPoint else { return } handleSelectionFromInteraction(point: point, anchorPoint: anchor) case .ended: isSelectionFromInteraction = false showSelectionMenuIfNeeded() default: break } } private func handleSelectionFromInteraction(point: CGPoint, anchorPoint: CGPoint?) { guard let page = currentPage else { return } let range: NSRange? if let anchor = anchorPoint { range = interactionController.selectionRange(from: anchor, to: point) } else if let idx = interactionController.characterIndex(at: point) { range = NSRange(location: idx, length: 1) } else { range = nil } guard let range else { return } let rects = interactionController.selectionRects(for: range) overlayView.updateSelection(absoluteRange: range, rects: rects) selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range) notifySelectionChange(range: range, page: page) } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { clearSelection() } @objc private func rd_copy(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .copy) } @objc private func rd_highlight(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .highlight) } @objc private func rd_annotate(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .annotate) } private func notifySelectionChange(range: NSRange, page: RDEPUBTextPage) { let source = page.chapterContent.string as NSString let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedText.isEmpty else { delegate?.textContentView(self, didChangeSelection: nil) return } let chapterLength = max(page.chapterContent.length - 1, 1) let chapterStart = max(range.location, 0) let chapterEnd = max(chapterStart + range.length - 1, chapterStart) let selection = RDEPUBSelection( location: RDEPUBLocation( href: page.href, progression: Double(chapterStart) / Double(chapterLength), lastProgression: Double(chapterEnd) / Double(chapterLength), fragment: nil ), text: selectedText, rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString() ) delegate?.textContentView(self, didChangeSelection: selection) } private func showSelectionMenuIfNeeded() { #if canImport(DTCoreText) guard overlayView.selectionRange?.length ?? 0 > 0, let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else { return } becomeFirstResponder() let menuRect = overlayView.convert(anchorRect, to: self) let menuController = UIMenuController.shared menuController.menuItems = [ UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))), UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))), UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:))) ] menuController.setTargetRect(menuRect, in: self) menuController.setMenuVisible(true, animated: true) #endif } private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) { applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset) } private func applyHighlights( to content: NSMutableAttributedString, page: RDEPUBTextPage, contentBaseOffset: Int ) { let pageRange = absoluteOffsetRange(for: page) let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound for highlight in highlightedRanges where highlight.location.href == page.href { guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue } let overlapStart = max(range.location, pageStart) let overlapEnd = min(range.location + range.length, pageEndExclusive) guard overlapStart < overlapEnd else { continue } let relativeRange = NSRange( location: overlapStart - contentBaseOffset, length: overlapEnd - overlapStart ) switch highlight.style { case .highlight: content.addAttribute( .backgroundColor, value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45), range: relativeRange ) case .underline: content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange) if let color = UIColor(rdHexString: highlight.color, alpha: 1) { content.addAttribute(.underlineColor, value: color, range: relativeRange) } } } } private func applySearchHighlights( to content: NSMutableAttributedString, page: RDEPUBTextPage, searchState: RDEPUBSearchState? ) { applySearchHighlights( to: content, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset ) } private func applySearchHighlights( to content: NSMutableAttributedString, page: RDEPUBTextPage, searchState: RDEPUBSearchState?, contentBaseOffset: Int ) { guard let searchState else { return } let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75) let pageRange = absoluteOffsetRange(for: page) let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound for match in searchState.matches where match.href == page.href { guard let matchStart = match.rangeLocation else { continue } let matchEnd = matchStart + match.rangeLength let overlapStart = max(matchStart, pageStart) let overlapEnd = min(matchEnd, pageEndExclusive) guard overlapStart < overlapEnd else { continue } let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart)) let color = match == searchState.currentMatch ? activeColor : normalColor content.addAttribute(.backgroundColor, value: color, range: relativeRange) } } private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range { let lowerBound = page.pageStartOffset let upperBound = page.pageEndOffset + 1 return lowerBound.. (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) { var background: [RDEPUBTextOverlayDecoration] = [] var foreground: [RDEPUBTextOverlayDecoration] = [] let pageRange = absoluteOffsetRange(for: page) let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound // Search results → background (behind text) if let searchState = currentSearchState { let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75) for match in searchState.matches where match.href == page.href { guard let matchStart = match.rangeLocation else { continue } let matchEnd = matchStart + match.rangeLength let overlapStart = max(matchStart, pageStart) let overlapEnd = min(matchEnd, pageEndExclusive) guard overlapStart < overlapEnd else { continue } let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart) let rects = interactionController.selectionRects(for: absoluteRange) guard !rects.isEmpty else { continue } let isActive = match == searchState.currentMatch let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search let color = isActive ? activeColor : normalColor background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color)) } } // Highlights → background (filled) or foreground (underline) for highlight in highlightedRanges where highlight.location.href == page.href { guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue } let overlapStart = max(range.location, pageStart) let overlapEnd = min(range.location + range.length, pageEndExclusive) guard overlapStart < overlapEnd else { continue } let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart) let rects = interactionController.selectionRects(for: absoluteRange) guard !rects.isEmpty else { continue } let color = UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45) let decoration = RDEPUBTextOverlayDecoration( kind: highlight.style == .underline ? .underline : .highlight, absoluteRange: absoluteRange, rects: rects, color: color ) if decoration.kind == .underline { foreground.append(decoration) } else { background.append(decoration) } } return (background, foreground) } #endif 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 textView.isHidden = true textView.isUserInteractionEnabled = false #endif textView.attributedText = nil 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 selectionProxyContent(from content: NSAttributedString) -> NSAttributedString { let proxy = NSMutableAttributedString(attributedString: content) let fullRange = NSRange(location: 0, length: proxy.length) proxy.removeAttribute(.backgroundColor, range: fullRange) proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange) var attachmentRanges: [NSRange] = [] proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in guard value != nil else { return } attachmentRanges.append(range) } for range in attachmentRanges.reversed() { let replacement = NSAttributedString( string: String(repeating: " ", count: max(range.length, 1)), attributes: [ .font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any, .foregroundColor: UIColor.clear ] ) proxy.replaceCharacters(in: range, with: replacement) } return proxy } private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { let content = NSMutableAttributedString(attributedString: page.content) guard shouldNormalizeContinuationParagraph(for: page) 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 } 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) } return content } 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) } #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 = DTCoreTextLayouter(attributedString: displayContent) else { coreTextContentView.layoutFrame = nil interactionController.configure(layoutFrame: nil, page: page) return } layouter.shouldCacheLayoutFrames = false let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) coreTextContentView.layoutFrame = layoutFrame interactionController.configure(layoutFrame: layoutFrame, page: page) overlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot) } #endif } extension RDEPUBTextContentView: UITextViewDelegate { func textViewDidChangeSelection(_ textView: UITextView) { guard !isSelectionFromInteraction else { return } guard let page = currentPage else { delegate?.textContentView(self, didChangeSelection: nil) return } let selectedRange = textView.selectedRange guard selectedRange.location != NSNotFound, selectedRange.length > 0, let attributedText = textView.attributedText else { delegate?.textContentView(self, didChangeSelection: nil) return } let source = attributedText.string as NSString let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedText.isEmpty else { delegate?.textContentView(self, didChangeSelection: nil) return } let globalStart = page.pageStartOffset + selectedRange.location let globalEnd = globalStart + selectedRange.length let totalLength = max(page.chapterContent.length - 1, 1) let selection = RDEPUBSelection( location: RDEPUBLocation( href: page.href, progression: Double(globalStart) / Double(totalLength), lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength), fragment: nil ), text: selectedText, rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() ) delegate?.textContentView(self, didChangeSelection: selection) } }