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, selection: RDEPUBSelection? ) } // MARK: - 文本内容视图 /// EPUB 流式排版的文本内容视图 /// 支持两种渲染路径: /// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制 /// 2. 回退路径:通过 UITextView 的 attributedText 渲染 /// /// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示 final class RDEPUBTextContentView: UIView { private static let darkAdjustedImageCache = NSCache() private var contentInsets: UIEdgeInsets = .zero private var currentPage: RDEPUBTextPage? private var currentSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection? weak var delegate: RDEPUBTextContentViewDelegate? #if canImport(DTCoreText) private let coreTextContentView: RDEPUBTextPageRenderView = { let view = RDEPUBTextPageRenderView() view.backgroundColor = .clear view.isOpaque = false return view }() private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayRange: NSRange? #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 textView: RDEPUBSelectableTextView = { let view = RDEPUBSelectableTextView() view.isEditable = false view.isScrollEnabled = false view.isSelectable = true view.backgroundColor = .clear view.accessibilityIdentifier = "epub.reader.selection.text" 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 }() private lazy var selectionLongPressGesture: UILongPressGestureRecognizer = { let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) gesture.minimumPressDuration = 0.4 return gesture }() private lazy var selectionTapGesture: UITapGestureRecognizer = { let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) gesture.numberOfTapsRequired = 1 gesture.require(toFail: selectionLongPressGesture) return gesture }() private lazy var selectionActionBar: UIStackView = { let stack = UIStackView(arrangedSubviews: [ selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))), selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))), selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:))) ]) stack.axis = .horizontal stack.alignment = .fill stack.distribution = .fillEqually stack.spacing = 1 stack.backgroundColor = UIColor(white: 0.12, alpha: 0.96) stack.layer.cornerRadius = 10 stack.layer.masksToBounds = true stack.layer.zPosition = 100 stack.isHidden = true stack.accessibilityIdentifier = "epub.reader.selection.menu" return stack }() override init(frame: CGRect) { super.init(frame: frame) addSubview(coverImageView) #if canImport(DTCoreText) addSubview(backgroundOverlayView) addSubview(coreTextContentView) #endif addSubview(overlayView) addSubview(textView) addSubview(pageNumberLabel) addSubview(selectionActionBar) textView.delegate = selectionController textView.onSelectionAction = { [weak self] action in guard let self else { return } self.delegate?.textContentView( self, didRequestSelectionAction: action, selection: self.resolvedCurrentSelection() ) } selectionController.onSelectionChanged = { [weak self] selection in guard let self else { return } if let selection { self.currentSelection = selection } self.delegate?.textContentView(self, didChangeSelection: selection) } addGestureRecognizer(selectionLongPressGesture) addGestureRecognizer(selectionTapGesture) if #available(iOS 16.0, *) { addInteraction(UIEditMenuInteraction(delegate: self)) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var canBecomeFirstResponder: Bool { true } override func target(forAction action: Selector, withSender sender: Any?) -> Any? { #if canImport(DTCoreText) switch action { case #selector(rd_copy(_:)), #selector(rd_highlight(_:)), #selector(rd_annotate(_:)): return self default: return super.target(forAction: action, withSender: sender) } #else return super.target(forAction: action, withSender: sender) #endif } 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 selectionController.canPerformSelectionAction(in: overlayView) 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 ) updateSelectionActionBarFrame() } func configure( page: RDEPUBTextPage, pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration, highlights: [RDEPUBHighlight] = [], searchState: RDEPUBSearchState? = nil ) { currentPage = page currentSelection = nil menuSelection = nil hideSelectionActionBar() 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 = darkImageAdjustedContentIfNeeded( normalizedPageContent(from: page), configuration: configuration ) 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 = false textView.isUserInteractionEnabled = true textView.tintColor = configuration.theme.toolControlTextColor textView.attributedText = selectionProxyContent(from: selectionContent) textView.selectedRange = NSRange(location: 0, length: 0) updateSelectionInteractionMode(usingNativeTextSelection: true) updateCoreTextLayoutFrameIfNeeded() #else overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset) overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset) textView.isHidden = false textView.isUserInteractionEnabled = true updateSelectionInteractionMode(usingNativeTextSelection: false) #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) = overlayView.buildDecorations( page: page, highlights: highlights, searchState: searchState, interactionController: interactionController ) backgroundOverlayView.applyDecorations(bgDecorations) overlayView.applyDecorations(fgDecorations) #endif delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() } func clearSelection() { currentSelection = nil menuSelection = nil hideSelectionActionBar() selectionController.clearSelection( textView: textView, overlayView: overlayView, backgroundOverlayView: backgroundOverlayView ) } // MARK: - Gesture Handling @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { selectionController.handleLongPress( gesture, page: currentPage, overlayView: overlayView, interactionController: interactionController ) if gesture.state == .ended { showSelectionMenuIfNeeded() } } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { currentSelection = nil menuSelection = nil hideSelectionActionBar() selectionController.handleTap( textView: textView, overlayView: overlayView, backgroundOverlayView: backgroundOverlayView ) } @objc private func rd_copy(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .copy, selection: resolvedCurrentSelection()) } @objc private func rd_highlight(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .highlight, selection: resolvedCurrentSelection()) } @objc private func rd_annotate(_ sender: Any?) { delegate?.textContentView(self, didRequestSelectionAction: .annotate, selection: resolvedCurrentSelection()) } private func resolvedCurrentSelection() -> RDEPUBSelection? { currentSelection ?? menuSelection ?? selectionFromOverlayRange() } private func selectionFromOverlayRange() -> RDEPUBSelection? { guard let page = currentPage, let range = overlayView.selectionRange, range.length > 0 else { return nil } let source = page.chapterContent.string as NSString let safeRange = NSIntersectionRange( range, NSRange(location: 0, length: page.chapterContent.length) ) guard safeRange.length > 0 else { return nil } let selectedText = source.substring(with: safeRange).trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedText.isEmpty else { return nil } let chapterLength = max(page.chapterContent.length - 1, 1) let chapterStart = max(safeRange.location, 0) let chapterEnd = max(chapterStart + safeRange.length - 1, chapterStart) return 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: safeRange.location, end: safeRange.location + safeRange.length ).jsonString() ) } private func showSelectionMenuIfNeeded() { #if canImport(DTCoreText) guard selectionLongPressGesture.isEnabled else { return } menuSelection = resolvedCurrentSelection() guard menuSelection != nil else { return } if showSelectionActionBarIfNeeded() { return } if #available(iOS 16.0, *), let editMenuInteraction = interactions.compactMap({ $0 as? UIEditMenuInteraction }).first, let targetRect = currentSelectionMenuTargetRect() { becomeFirstResponder() let sourcePoint = CGPoint(x: targetRect.midX, y: targetRect.midY) editMenuInteraction.presentEditMenu( with: UIEditMenuConfiguration(identifier: nil, sourcePoint: sourcePoint) ) return } selectionController.showSelectionMenuIfNeeded( in: self, overlayView: overlayView, interactionController: interactionController, copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)), highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)), annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:)) ) #endif } private func selectionMenuButton(title: String, action: Selector) -> UIButton { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14) button.backgroundColor = .clear button.accessibilityLabel = title button.accessibilityIdentifier = "epub.reader.selection.\(title)" button.addTarget(self, action: action, for: .touchUpInside) return button } private func showSelectionActionBarIfNeeded() -> Bool { guard currentSelectionMenuTargetRect() != nil else { return false } selectionActionBar.isHidden = false updateSelectionActionBarFrame() bringSubviewToFront(selectionActionBar) return true } private func hideSelectionActionBar() { selectionActionBar.isHidden = true } private func updateSelectionActionBarFrame() { guard !selectionActionBar.isHidden, let targetRect = currentSelectionMenuTargetRect() else { return } let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let width = max(fittingSize.width, 168) let height = max(fittingSize.height, 42) let horizontalPadding: CGFloat = 12 let x = min( max(targetRect.midX - width / 2, horizontalPadding), max(horizontalPadding, bounds.width - width - horizontalPadding) ) let preferredY = targetRect.minY - height - 8 let y = preferredY >= 8 ? preferredY : min(targetRect.maxY + 8, bounds.height - height - 8) selectionActionBar.frame = CGRect(x: x, y: max(8, y), width: width, height: height) } private func updateSelectionInteractionMode(usingNativeTextSelection: Bool) { selectionLongPressGesture.isEnabled = !usingNativeTextSelection selectionTapGesture.isEnabled = !usingNativeTextSelection } private func currentSelectionMenuTargetRect() -> CGRect? { guard let range = overlayView.selectionRange, range.length > 0, let anchorRect = interactionController.menuAnchorRect(for: range) else { return nil } return overlayView.convert(anchorRect, to: self) } 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 } #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 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 } @available(iOS 16.0, *) extension RDEPUBTextContentView: UIEditMenuInteractionDelegate { func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement] ) -> UIMenu? { guard resolvedCurrentSelection() != nil else { return nil } return UIMenu(children: [ UIAction(title: "拷贝") { [weak self] _ in guard let self else { return } self.delegate?.textContentView( self, didRequestSelectionAction: .copy, selection: self.resolvedCurrentSelection() ) }, UIAction(title: "高亮") { [weak self] _ in guard let self else { return } self.delegate?.textContentView( self, didRequestSelectionAction: .highlight, selection: self.resolvedCurrentSelection() ) }, UIAction(title: "批注") { [weak self] _ in guard let self else { return } self.delegate?.textContentView( self, didRequestSelectionAction: .annotate, selection: self.resolvedCurrentSelection() ) } ]) } func editMenuInteraction( _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration ) -> CGRect { currentSelectionMenuTargetRect() ?? bounds } } private extension UIColor { var rd_isDarkReaderBackground: 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.2126 * red + 0.7152 * green + 0.0722 * blue return luminance < 0.35 } }