Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift
T
2026-05-26 09:19:37 +08:00

314 lines
13 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: - 私有工具方法
/// 将页面的起止偏移量转换为半开区间 [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)
}
}