feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
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) {
|
||||
@@ -16,88 +11,66 @@ public final class RDEPUBChapterData {
|
||||
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)
|
||||
@@ -115,7 +88,6 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
@@ -124,32 +96,31 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将页面转换为对应的 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 cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -164,16 +135,35 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
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 cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let location = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: location,
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
|
||||
)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||
}
|
||||
@@ -183,8 +173,17 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将持久化位置还原为全书字符范围。
|
||||
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -195,8 +194,16 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原全书字符范围。
|
||||
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -207,8 +214,19 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从搜索结果还原全书字符范围。
|
||||
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let chapterLocation = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: indexTable.globalIndex(for: anchor),
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
|
||||
)
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -219,13 +237,14 @@ public final class RDEPUBChapterData {
|
||||
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 cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
|
||||
}
|
||||
if let anchor = highlight.location.rangeAnchor?.start {
|
||||
return pageRange.contains(absoluteOffset(for: anchor))
|
||||
}
|
||||
@@ -236,7 +255,6 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
@@ -247,26 +265,20 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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?
|
||||
@@ -278,7 +290,6 @@ public final class RDEPUBChapterData {
|
||||
return chapterHref == itemHref
|
||||
}
|
||||
|
||||
/// 过滤出当前章节命中的目录条目。
|
||||
public func tableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
@@ -289,7 +300,6 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前章节最合适的目录条目(通常取命中的首个最近条目)。
|
||||
public func primaryTableOfContentsItem(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
@@ -297,10 +307,6 @@ public final class RDEPUBChapterData {
|
||||
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,
|
||||
@@ -324,17 +330,51 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.chapterOffset(for: startAnchor)
|
||||
let end = indexTable.chapterOffset(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.globalIndex(for: startAnchor)
|
||||
let end = indexTable.globalIndex(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func recoveredSearchRangeLength(
|
||||
for searchMatch: RDEPUBSearchMatch,
|
||||
cfi: RDEPUBCFI,
|
||||
startOffset: Int
|
||||
) -> Int {
|
||||
let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
|
||||
let fallbackLength = max(searchMatch.rangeLength, 1)
|
||||
let candidateLength = exactLength > 0 ? exactLength : fallbackLength
|
||||
let remainingLength = max(attributedContent.length - startOffset, 1)
|
||||
return min(max(candidateLength, 1), remainingLength)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user