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? ) func textContentView( _ contentView: RDEPUBTextContentView, didRequestHighlightActions highlight: RDEPUBHighlight, sourceRect: CGRect ) } // MARK: - 文本内容视图 /// EPUB 流式排版的文本内容视图 /// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。 final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { private static let darkAdjustedImageCache = NSCache() private var contentInsets: UIEdgeInsets = .zero private var currentPage: RDEPUBTextPage? private var currentSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection? private var currentHighlights: [RDEPUBHighlight] = [] 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 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 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 }() // MARK: - Init 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) addGestureRecognizer(longPressGestureRecognizer) addGestureRecognizer(panGestureRecognizer) addGestureRecognizer(tapGestureRecognizer) tapGestureRecognizer.require(toFail: longPressGestureRecognizer) selectionController.onSelectionChanged = { [weak self] selection in guard let self else { return } self.currentSelection = selection if let selection { self.menuSelection = selection } else { self.hideSelectionMenu() } self.delegate?.textContentView(self, didChangeSelection: selection) } selectionController.pageProvider = { [weak self] in self?.currentPage } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - First Responder override var canBecomeFirstResponder: Bool { true } 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) } // MARK: - Layout 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 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 ) } // MARK: - Configure func configure( page: RDEPUBTextPage, pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration, highlights: [RDEPUBHighlight] = [], searchState: RDEPUBSearchState? = nil ) { currentPage = page currentSelection = nil menuSelection = nil currentHighlights = highlights selectionController.clearSelection(renderView: coreTextRenderView) 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 #endif delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() return } coverImageView.isHidden = true coverImageView.image = nil #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 ) // 注入高亮/下划线自定义属性(对齐 WXRead 的 WRChapterData.addHighlightInRange:) applyHighlightsToContent(displayContent, highlights: highlights, page: page) coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text" coreTextDisplayContent = displayContent coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) 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) let (bgDecorations, fgDecorations) = overlayView.buildDecorations( page: page, highlights: [], searchState: searchState, interactionController: interactionController ) backgroundOverlayView.applyDecorations(bgDecorations) overlayView.applyDecorations(fgDecorations) #endif delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() } func clearSelection() { currentSelection = nil menuSelection = nil panGestureRecognizer.isEnabled = false selectionController.clearSelection(renderView: coreTextRenderView) overlayView.clearSelection() backgroundOverlayView.clearSelection() UIMenuController.shared.setMenuVisible(false, animated: true) } // MARK: - 选择操作 private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) { let selection = currentSelection ?? menuSelection delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection) menuSelection = nil hideSelectionMenu() } // MARK: - Cover Image 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 #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 } // MARK: - Dark Image Adjustment #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 // MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:) private func applyHighlightsToContent( _ content: NSMutableAttributedString, highlights: [RDEPUBHighlight], page: RDEPUBTextPage ) { for highlight in highlights { 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 else { continue } let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length) guard relativeRange.location >= 0, relativeRange.location + relativeRange.length <= content.length else { continue } switch highlight.style { case .highlight: content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange) case .underline: content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange) } } } // MARK: - Content Normalization 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) } // MARK: - CoreText Layout #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 // MARK: - Gestures @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { #if canImport(DTCoreText) selectionController.handleLongPress( gesture, renderView: coreTextRenderView, interactionController: interactionController ) switch gesture.state { case .began: panGestureRecognizer.isEnabled = true case .ended: panGestureRecognizer.isEnabled = false showSelectionMenuIfNeeded() case .cancelled, .failed: panGestureRecognizer.isEnabled = false default: break } #endif } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { #if canImport(DTCoreText) selectionController.handlePan( gesture, renderView: coreTextRenderView, interactionController: interactionController ) switch gesture.state { case .ended: panGestureRecognizer.isEnabled = false showSelectionMenuIfNeeded() case .cancelled, .failed: panGestureRecognizer.isEnabled = false default: break } #endif } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: overlayView) if currentSelection != nil { clearSelection() return } guard let highlight = highlight(at: point), let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { return } delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) } private func showSelectionMenuIfNeeded() { guard currentSelection != nil, let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), !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) } private func hideSelectionMenu() { UIMenuController.shared.setMenuVisible(false, animated: true) } private var coreTextRenderView: RDEPUBTextPageRenderView? { #if canImport(DTCoreText) return coreTextContentView #else return nil #endif } private func highlight(at point: CGPoint) -> RDEPUBHighlight? { guard let page = currentPage else { return nil } let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point) guard let absoluteRange else { return nil } let matches = currentHighlights.filter { highlight in guard highlight.location.href == page.href, let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { return false } return NSIntersectionRange(range, absoluteRange).length > 0 } 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) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === panGestureRecognizer { return selectionController.isSelecting } if gestureRecognizer === tapGestureRecognizer { guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else { return false } if selectionController.hasActiveSelection { return true } let point = tapGestureRecognizer.location(in: overlayView) return highlight(at: point) != nil } return true } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer } } // MARK: - UIColor Extension private extension UIColor { var rd_isDarkReaderBackground: Bool { var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false } return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35 } }