feat(epub): complete wxread parity scope
This commit is contained in:
@@ -30,8 +30,19 @@ public final class RDEPUBChapterData {
|
||||
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: - 页面查询
|
||||
|
||||
@@ -52,20 +63,36 @@ public final class RDEPUBChapterData {
|
||||
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 三元组)
|
||||
/// 将章节内字符索引转换为语义锚点(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: 选区在全书中的字符范围
|
||||
@@ -102,9 +129,21 @@ public final class RDEPUBChapterData {
|
||||
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(锚点定位,最精确)
|
||||
@@ -112,7 +151,7 @@ public final class RDEPUBChapterData {
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let fragment = location.fragment,
|
||||
@@ -128,7 +167,7 @@ public final class RDEPUBChapterData {
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
@@ -136,7 +175,7 @@ public final class RDEPUBChapterData {
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
@@ -144,6 +183,42 @@ public final class RDEPUBChapterData {
|
||||
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: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
@@ -163,7 +238,6 @@ public final class RDEPUBChapterData {
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
if let range = absoluteRange(for: match) {
|
||||
@@ -173,12 +247,54 @@ 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? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
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 nil
|
||||
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: - 私有工具方法
|
||||
@@ -190,12 +306,8 @@ public final class RDEPUBChapterData {
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
/// 将语义锚点转换为章节内字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
indexTable.chapterOffset(for: anchor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ public struct RDEPUBTextBook {
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
|
||||
public var positionConverter: RDEPUBTextPositionConverter {
|
||||
RDEPUBTextPositionConverter(book: self)
|
||||
}
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
@@ -93,12 +97,47 @@ public struct RDEPUBTextBook {
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按 spine 索引获取章节的数据访问层。
|
||||
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按绝对页码(从 1 开始)获取章节的数据访问层。
|
||||
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
|
||||
guard let page = page(at: pageNumber) else { return nil }
|
||||
return chapterData(forSpineIndex: page.spineIndex)
|
||||
}
|
||||
|
||||
/// 根据持久化位置解析所属章节的数据访问层。
|
||||
public func chapterData(
|
||||
for location: RDEPUBLocation,
|
||||
resolver: RDEPUBResourceResolver,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBChapterData? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: chapter.spineIndex,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
@@ -115,51 +154,30 @@ public struct RDEPUBTextBook {
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
let chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = indexTable.pageNumber(for: anchor, in: self) {
|
||||
return page + 1
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
if let anchor = indexTable.anchor(for: normalizedLocation),
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
guard let chapterData = chapterData(forPageNumber: pageNumber),
|
||||
let page = page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
|
||||
let startAnchor = indexTable.anchor(forAbsoluteIndex: page.pageStartOffset, in: chapter)
|
||||
let endAnchor = indexTable.anchor(forAbsoluteIndex: page.pageEndOffset, in: chapter)
|
||||
let rangeAnchor = RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@ public struct RDEPUBTextIndexTable {
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 全书总字符数(章节长度累加)
|
||||
public var totalCharacterCount: Int {
|
||||
guard let lastIndex = chapterStartOffsets.indices.last,
|
||||
chapterLengths.indices.contains(lastIndex) else {
|
||||
return 0
|
||||
}
|
||||
return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
|
||||
}
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
@@ -118,11 +127,29 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移量构建文本锚点。
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
guard let fileIndex = fileIndex(forCharacterPosition: index),
|
||||
let href = href(for: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = absoluteIndex(for: anchor)
|
||||
let resolvedOffset = chapterOffset(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
@@ -138,31 +165,77 @@ public struct RDEPUBTextIndexTable {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
/// 通过文件索引获取章节起始的全书字符偏移量。
|
||||
public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterStartOffsets.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterStartOffsets[chapterIndex]
|
||||
}
|
||||
|
||||
/// 通过文件索引获取章节长度。
|
||||
public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterLengths.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterLengths[chapterIndex]
|
||||
}
|
||||
|
||||
/// 将锚点转换为章节内字符偏移量。
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
chapterOffset(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
/// 将锚点转换为全书绝对字符索引。
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
let chapterOffset = chapterOffset(for: anchor)
|
||||
let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
|
||||
let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
|
||||
let lastOffset = max(chapterLength - 1, 0)
|
||||
return baseOffset + min(max(chapterOffset, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 兼容旧调用:返回全书绝对字符索引。
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为章节内 NSRange。
|
||||
public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = globalIndex(for: rangeAnchor.start)
|
||||
let end = max(start, globalIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 兼容旧调用:返回全书绝对范围。
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let absoluteOffset = absoluteIndex(for: anchor)
|
||||
let chapterOffset = self.chapterOffset(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
|
||||
let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
@@ -179,8 +252,8 @@ public struct RDEPUBTextIndexTable {
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
@@ -213,8 +286,8 @@ public struct RDEPUBTextIndexTable {
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
/// 根据文件索引、行号和列号计算章节内偏移量。
|
||||
public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
@@ -222,6 +295,46 @@ public struct RDEPUBTextIndexTable {
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引。
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
|
||||
let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
return baseOffset + chapterOffset
|
||||
}
|
||||
|
||||
/// 全书字符偏移量 → 文件索引。
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
guard totalCharacterCount > 0 else { return nil }
|
||||
let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
|
||||
for (href, chapterIndex) in hrefToChapterIndex {
|
||||
guard chapterStartOffsets.indices.contains(chapterIndex),
|
||||
chapterLengths.indices.contains(chapterIndex),
|
||||
let fileIndex = hrefToFileIndex[href] else {
|
||||
continue
|
||||
}
|
||||
|
||||
let start = chapterStartOffsets[chapterIndex]
|
||||
let length = chapterLengths[chapterIndex]
|
||||
let endExclusive = start + max(length, 1)
|
||||
if normalized >= start && normalized < endExclusive {
|
||||
return fileIndex
|
||||
}
|
||||
}
|
||||
return fileIndexToHref.keys.sorted().last
|
||||
}
|
||||
|
||||
/// 全书字符偏移量 → 指定文件内的章节偏移量。
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
guard let start = chapterStartOffset(forFileIndex: fileIndex),
|
||||
let chapterLength = chapterLength(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
|
||||
return normalized - start
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
@@ -243,6 +356,21 @@ public struct RDEPUBTextIndexTable {
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 查找给定 href 中距离偏移量最近(且在其之前)的 fragment ID。
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
|
||||
guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||||
bestOffset = fragmentOffset
|
||||
bestID = id
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。
|
||||
///
|
||||
/// 负责在四套坐标之间做双向转换:
|
||||
/// - `(fileIndex, row, column)` 文件级语义锚点
|
||||
/// - 章节内字符偏移
|
||||
/// - 全书字符偏移
|
||||
/// - 页码 / `RDEPUBLocation`
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
public let book: RDEPUBTextBook
|
||||
|
||||
public init(book: RDEPUBTextBook) {
|
||||
self.book = book
|
||||
}
|
||||
|
||||
public var totalCharacterCount: Int {
|
||||
book.indexTable.totalCharacterCount
|
||||
}
|
||||
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
book.indexTable.globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
book.indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
book.indexTable.fileIndex(forCharacterPosition: index)
|
||||
}
|
||||
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
|
||||
}
|
||||
|
||||
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(for: location)
|
||||
}
|
||||
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
|
||||
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
|
||||
}
|
||||
|
||||
public func pageNumber(forCharacterPosition index: Int) -> Int? {
|
||||
guard let anchor = anchor(forCharacterPosition: index) else {
|
||||
return nil
|
||||
}
|
||||
return pageNumber(for: anchor)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == rangeAnchor.start.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: rangeAnchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
@@ -27,6 +28,8 @@ enum RDEPUBTextRendererSupport {
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
private static var didLogCoverAttachment = false
|
||||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
|
||||
// MARK: - Fragment 标记注入/提取
|
||||
|
||||
@@ -209,6 +212,11 @@ enum RDEPUBTextRendererSupport {
|
||||
contentLanguageCode: contentLanguageCode,
|
||||
sourceHTML: rawHTML
|
||||
)
|
||||
registerEmbeddedFonts(
|
||||
in: stylesheetHrefReplacements.inlinedCSS + "\n" + inlineStyleCSS(in: normalizedHTML),
|
||||
chapterHref: href,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||
let htmlWithDefaultLayers = injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
@@ -327,6 +335,58 @@ enum RDEPUBTextRendererSupport {
|
||||
return RDEPUBAssetRepository.string(for: asset)
|
||||
}
|
||||
|
||||
private static func inlineStyleCSS(in html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
|
||||
return ""
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
.compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
return nsHTML.substring(with: match.range(at: 1))
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func registerEmbeddedFonts(
|
||||
in css: String,
|
||||
chapterHref: String,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) {
|
||||
guard let resourceResolver,
|
||||
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
|
||||
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
|
||||
guard faceMatch.numberOfRanges > 1 else { continue }
|
||||
let block = nsCSS.substring(with: faceMatch.range(at: 1))
|
||||
let nsBlock = block as NSString
|
||||
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
|
||||
guard urlMatch.numberOfRanges > 1 else { continue }
|
||||
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
|
||||
guard !rawReference.isEmpty,
|
||||
!rawReference.hasPrefix("data:"),
|
||||
!rawReference.hasPrefix("http://"),
|
||||
!rawReference.hasPrefix("https://"),
|
||||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||||
continue
|
||||
}
|
||||
registerFontIfNeeded(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func registerFontIfNeeded(at fileURL: URL) {
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||||
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
}
|
||||
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user