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? { 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 location = searchMatch.rangeLocation { return NSRange(location: location, length: max(searchMatch.rangeLength, 1)) } if let rangeAnchor = searchMatch.rangeAnchor { return indexTable.chapterRange(for: rangeAnchor) } 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 { let lowerBound = page.pageStartOffset let upperBound = page.pageEndOffset + 1 return lowerBound.. Int { indexTable.chapterOffset(for: anchor) } }