ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift
shenlei 70133e4e6e refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案
- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:57:27 +08:00

341 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
/// 访便
///
/// `RDEPUBTextChapter` `indexTable`
/// fragment API
/// `RDEPUBTextBook.chapterData(for:)` `chapterData(atChapterIndex:)`
public final class RDEPUBChapterData {
///
public let chapter: RDEPUBTextChapter
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
self.chapter = chapter
self.indexTable = indexTable
}
// MARK: - 便 chapter
///
public var chapterIndex: Int { chapter.chapterIndex }
/// spine
public var spineIndex: Int { chapter.spineIndex }
/// "OEBPS/chapter1.xhtml"
public var href: String { chapter.href }
///
public var title: String { chapter.title }
///
public var attributedContent: NSAttributedString { chapter.attributedContent }
///
public var pages: [RDEPUBTextPage] { chapter.pages }
///
public var pageCount: Int { chapter.pages.count }
/// fragment ID
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
/// /
public var chapterInfo: EPUBChapterInfo {
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
}
///
public var absolutePageRange: ClosedRange<Int>? {
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
}
// MARK: -
///
/// - Parameter absoluteOffset:
/// - Returns: nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
}
/// 0
public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex
}
///
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
}
/// 1
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
return pages[pageNumber - 1]
}
// MARK: -
/// fileIndex/row/column
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
}
///
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
indexTable.anchor(forGlobalIndex: index)
}
///
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
let start = anchor(forAbsoluteIndex: absoluteRange.location)
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
return RDEPUBTextRangeAnchor(start: start, end: end)
}
///
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
indexTable.globalRange(for: rangeAnchor)
}
/// /
/// - Parameters:
/// - absoluteRange:
/// - bookIdentifier:
/// - Returns: nil
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
guard page(containing: absoluteRange.location) != nil else { return nil }
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
let text = chapter.attributedContent.attributedSubstring(from: absoluteRange).string
let rangeInfo = RDEPUBTextOffsetRangeInfo(
href: chapter.href,
start: absoluteRange.location,
end: absoluteRange.location + absoluteRange.length
).jsonString()
return RDEPUBSelection(
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo
)
}
/// RDEPUBLocation
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location(
for: rangeAnchor(for: absoluteRange),
in: chapter,
bookIdentifier: bookIdentifier
)
}
/// RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier)
}
///
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: location) else { return nil }
return page(containing: range.location)
}
///
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: searchMatch) else { return nil }
return page(containing: range.location)
}
// MARK: - Location
///
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression退
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let rangeAnchor = location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
if let fragment = location.fragment,
let offset = fragmentOffsets[fragment] {
return NSRange(location: offset, length: 1)
}
let lastOffset = max(attributedContent.length - 1, 0)
let offset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
return NSRange(location: offset, length: 1)
}
///
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
}
///
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
if let location = searchMatch.rangeLocation {
return NSRange(location: location, length: searchMatch.rangeLength)
}
return nil
}
///
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
if let rangeAnchor = location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
guard let chapterRange = absoluteRange(for: location),
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
return nil
}
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
///
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
guard let chapterRange = absoluteRange(for: highlight),
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
return nil
}
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
///
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
guard let chapterRange = absoluteRange(for: searchMatch),
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
return nil
}
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
// MARK: - /
///
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in
guard highlight.location.href == chapter.href else { return false }
if let anchor = highlight.location.rangeAnchor?.start {
return pageRange.contains(absoluteOffset(for: anchor))
}
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
return NSIntersectionRange(range, page.contentRange).length > 0
}
}
///
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
return matches.filter { match in
guard match.href == chapter.href else { return false }
if let range = absoluteRange(for: match) {
return NSIntersectionRange(range, page.contentRange).length > 0
}
return false
}
}
/// `searchMatches(on:from:)` /WXRead
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
searchMatches(on: page, from: matches)
}
/// 1
public func pageNumber(for location: RDEPUBLocation) -> Int? {
guard let page = page(for: location) else { return nil }
return page.absolutePageIndex + 1
}
/// 1
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let page = page(for: searchMatch) else { return nil }
return page.absolutePageIndex + 1
}
// MARK: -
/// TOC
public func contains(
tableOfContentsItem item: EPUBTableOfContentsItem,
normalizer: (String) -> String?
) -> Bool {
guard let chapterHref = normalizer(href),
let itemHref = normalizer(item.href.components(separatedBy: "#").first ?? item.href) else {
return false
}
return chapterHref == itemHref
}
///
public func tableOfContentsItems(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
) -> [EPUBTableOfContentsItem] {
items.flatMap { item -> [EPUBTableOfContentsItem] in
let descendants = tableOfContentsItems(from: item.children, normalizer: normalizer)
return contains(tableOfContentsItem: item, normalizer: normalizer) ? [item] + descendants : descendants
}
}
///
public func primaryTableOfContentsItem(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
) -> EPUBTableOfContentsItem? {
tableOfContentsItems(from: items, normalizer: normalizer).first
}
// MARK: - WXRead WRChapterData.addHighlightInRange:key:itemId:color:
/// /线 NSAttributedString
/// CoreText WXRead com.weread.highlight / com.weread.underline
public func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard highlight.location.href == chapter.href else { continue }
guard let range = absoluteRange(for: highlight) else { continue }
let overlap = NSIntersectionRange(range, 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: -
/// [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
/// 退 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.chapterOffset(for: anchor)
}
}