fix: 修复右对齐短文本显示不完整及多项功能改进

右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题):
- RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义,
  避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页
- RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落,
  防止错误修改 firstLineHeadIndent 导致首字不可见
- RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块
  自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进
- RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的
  firstLineHeadIndent 重置为 0

图片查看器及脚注点击功能:
- 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator,
  支持从 WebView 和 TextPage 两种模式查看图片
- epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗
- RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息
- RDEPUBWebView: 新增图片和脚注点击的 delegate 方法
- RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断
- RDEPUBPaginationModels: 新增 .footnote 附件类型
- RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片

位置解析改进:
- RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度

其他:
- RDEPUBTextBookCache: schema 版本升级至 13
- RDEPUBReaderTheme: 主题更新
- Pod 项目文件更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-25 15:39:58 +08:00
co-authored by Claude
parent d15f20b097
commit d7f28c3f10
21 changed files with 2649 additions and 1933 deletions
@@ -3,7 +3,7 @@ import UIKit
extension RDEPUBReaderController {
func pageNumber(for location: RDEPUBLocation) -> Int? {
func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
@@ -16,7 +16,8 @@ extension RDEPUBReaderController {
let localPageIndex = resolvedLocalPageIndex(
for: normalizedLocation,
spineIndex: spineIndex,
fallbackEntry: entry
fallbackEntry: entry,
rangeInfo: rangeInfo
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
@@ -25,17 +26,22 @@ extension RDEPUBReaderController {
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) {
return pageNumber
}
if let anchor = normalizedLocation.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
@@ -130,7 +136,10 @@ extension RDEPUBReaderController {
return (pages, chapters)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int {
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
return decodedRangeInfo.start
}
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let cfi = primaryLocationCFI(for: location),
@@ -167,9 +176,10 @@ extension RDEPUBReaderController {
private func resolvedLocalPageIndex(
for location: RDEPUBLocation,
spineIndex: Int,
fallbackEntry: RDEPUBBookPageMapEntry
fallbackEntry: RDEPUBBookPageMapEntry,
rangeInfo: String? = nil
) -> Int? {
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry)
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo)
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
@@ -193,6 +203,55 @@ extension RDEPUBReaderController {
return location.cfi
}
private func pageNumberFromRangeInfo(
_ rangeInfo: String?,
in textBook: RDEPUBTextBook,
matching location: RDEPUBLocation
) -> Int? {
guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location),
let chapterData = chapterData(in: textBook, for: decodedRangeInfo),
let page = chapterData.page(containing: decodedRangeInfo.start) else {
return nil
}
return page.absolutePageIndex + 1
}
private func decodedRangeInfo(
from rangeInfo: String?,
matching location: RDEPUBLocation
) -> RDEPUBTextOffsetRangeInfo? {
guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else {
return nil
}
return decodedRangeInfo
}
private func chapterData(
in textBook: RDEPUBTextBook,
for rangeInfo: RDEPUBTextOffsetRangeInfo
) -> RDEPUBChapterData? {
if let chapterData = textBook.chapterData(for: rangeInfo.href) {
return chapterData
}
let targetHref = normalizedHref(rangeInfo.href)
if let chapterData = textBook.chapterData(for: targetHref) {
return chapterData
}
guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else {
return nil
}
return textBook.chapterData(for: chapter.href)
}
private func normalizedHref(_ href: String) -> String {
publication?.resourceResolver.normalizedHref(href)
?? href.components(separatedBy: "#").first
?? href
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min