ReadViewSDK/Doc/WXRead/analysis/02_符号恢复与核心算法.md

780 lines
29 KiB
Markdown

# 读书 EPUB 阅读器 — 符号恢复与核心算法
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
> 方法签名来源: __objc_methname 段 + binary strings
> 类名来源: __objc_classname 段 (17584 个类, 其中 320 个 EPUB 相关)
---
## 一、符号恢复映射表
### 1.1 核心类方法签名恢复 (439 个)
#### WREpubTypesetter (EPUB 排版器入口)
```
恢复前 (二进制地址) 恢复后 (符号名) 功能
─────────────────────────────────────────────────────────────────────────────────────
imp@0x100a489ac +[WREpubTypesetter attributeStringWithFilePath: XHTML→NSAttributedString
priority: 核心排版方法
insertArticleToolAttachment: 5层CSS级联
insertBookChapterToolAttachment: 图片/链接处理
insertRecommendView: 繁简转换
book:chapter:pageFlippingStyle: 免费试读截断
renderErrorReason:isStyleFileNotFound:options:]
imp@0x100a49b9c +[WREpubTypesetter tryReportTranslationError: 翻译错误上报
bookId:chapter:
isTranslationStyleNotFound:
isTranslationContentNotFound:
isTranslateTagButNoTranslateStyle:]
```
#### DTHTMLAttributedStringBuilder (HTML→NSAttributedString 构建器)
```
imp@0x... -[DTHTMLAttributedStringBuilder _buildString] 核心构建方法
imp@0x... -[DTHTMLAttributedStringBuilder parser:didStartElement: SAX 标签开始
attributes:position:]
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCDATA:] CDATA 处理
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCharacters: 文本节点
position:]
imp@0x... -[DTHTMLAttributedStringBuilder parserDidEndDocument:] 解析完成
```
#### DTHTMLElement (HTML 元素模型, 168 methods)
```
imp@0x... -[DTHTMLElement applyStyleDictionary: CSS 样式应用
isLatinLanguageBook:]
imp@0x... -[DTHTMLElement attributedString] 生成富文本
imp@0x... -[DTHTMLElement interpretAttributes] 属性解析
```
#### WRCoreTextLayoutFrame (单页排版帧, 49 methods)
```
imp@0x... -[WRCoreTextLayoutFrame drawInContext: 核心绘制方法
image:size:inRect:position:] CGContext 绘制
imp@0x... -[WRCoreTextLayoutFrame avoidPageBreakInside 避免断页
ByRemovingLastLinesIfNeeded] 移除最多3行
imp@0x... -[WRCoreTextLayoutFrame getRenderHeight] 获取渲染高度
imp@0x... -[WRCoreTextLayoutFrame lines] 获取行列表
```
#### DTCoreTextGlyphRun (字形渲染)
```
imp@0x... -[DTCoreTextGlyphRun newPathWithGlyphs] CGPath 字形路径
CTFontCreatePathForGlyph
```
#### WRChapterData (章节数据模型, 125 methods)
```
imp@0x... +[WRChapterData addUnderLineToAttributedString: 添加下划线
range:itemId:style:color:]
imp@0x... +[WRChapterData freeTrialChapterCutOffStringLocaion 试读截断位置
WithAttributedString:book:]
imp@0x... -[WRChapterData addHighlightInRange: 添加高亮
key:itemId:color:]
imp@0x... -[WRChapterData addReviewUnderlineInRange: 添加书评下划线
itemId:type:]
imp@0x... -[WRChapterData rangeOfPage:] 获取页范围
imp@0x... -[WRChapterData generateOutlineContents] 生成目录
imp@0x... -[WRChapterData markFreeTrialChapterCutOffString 标记试读截断
Location:]
```
#### WRPageViewController (翻页控制器, 80 methods)
```
imp@0x... -[WRPageViewController initWithDelegate: 初始化
withPageType:pageFlippingStyle:]
imp@0x... -[WRPageViewController pageViewController: 上一页
viewControllerBeforeViewController:]
imp@0x... -[WRPageViewController pageViewController: 下一页
viewControllerAfterViewController:]
imp@0x... +[WRPageViewController patchNavigationDirectionFault] 导航方向修复
imp@0x... +[WRPageViewController patchNoViewController 页面管理修复
ManagingPageViewFault]
imp@0x... +[WRPageViewController patchUIPageCurlFault] 翻页动画修复
imp@0x... +[WRPageViewController detectNavigationDirection 崩溃检测
CrashWithPageViewController:...]
```
#### WRReaderViewController (阅读器主控制器, 431 methods)
```
imp@0x... -[WRReaderViewController initWithBook: 初始化
progress:forceUseInitialProgress:
doodleMode:autoRead:]
imp@0x... -[WRReaderViewController renderPageView: 渲染页面
progressData:source:]
imp@0x... -[WRReaderViewController recomposeCurrentPageView 重新排版
WithSource:]
imp@0x... -[WRReaderViewController gotoChapterIdx: 跳转章节
position:positionOfFile:]
imp@0x... -[WRReaderViewController _saveReadingProgress 保存进度
AndIsAsync:]
imp@0x... -[WRReaderViewController changeTypesetterAttributes 修改排版属性
WithBlock:]
imp@0x... -[WRReaderViewController initChapterPageCount] 初始化分页
```
#### WREncryptedFileManager (DRM 管理)
```
imp@0x... +[WREncryptedFileManager decryptContentsOfFile: AES 解密
forBookId:isFileLost:]
imp@0x... +[WREncryptedFileManager encryptFileForBookId: 本地加密
originalEncryptKey:atPath:toPath:]
imp@0x... +[WREncryptedFileManager keyForBookId:] 获取密钥
```
#### WRBookNetwork (网络层, 44 class methods)
```
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:] 下载加密 ZIP
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:] 批量下载
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:...] 解密解压
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:] 解压错误处理
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:...] 文件处理
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags: 读取内容
+:filterTranslateContent:]
+[WRBookNetwork loadChapterContentWithParam:callback:] 加载章节内容
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:] 章节信息解析
+[WRBookNetwork savePreloadInfoWithDownloadParam:...] 保存预加载信息
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:] 清理预加载缓存
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:...] 存储图片
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:] 加载书签
+[WRBookNetwork addReview:shareToWechat:audioArticleId:...] 添加书评
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:...] 搜索
+[WRBookNetwork uploadBookProgressAndReadingTime:...] 上传进度
+[WRBookNetwork loadBookInfoWithBookId:source:callback:] 书籍信息
+[WRBookNetwork loadBookReadInfoWithBookId:callback:] 阅读信息
+[WRBookNetwork fetchLockInfoWithBookId:callback:] 锁定信息
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:] 重置付费
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:] 标记阅读状态
+[WRBookNetwork setFinishReading:withBookId:callback:] 标记完成
+[WRBookNetwork setIsStartReading:withBookId:callback:] 标记开始
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:] 点赞
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:] 点踩
+[WRBookNetwork repostReview:reposted:callback:] 转发
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:] 打赏
+[WRBookNetwork postReviewHideWithBookId:hide:callback:] 隐藏书评
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:...] 阅读时长福利
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:...] 自动标记完成
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:...] 轮询翻译状态
+[WRBookNetwork _removeTranslateHtml:] 移除翻译HTML
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:] 处理章节信息
```
### 1.2 ivar 恢复表 (关键类)
```
WREpubTypesetter (4 ivars):
_cssFilePath NSString 内嵌 CSS 文件路径
_replaceCSSPath NSString replace.css 路径
_darkCSSPath NSString dark.css 路径
_options NSArray 排版选项
WRChapterData (12 ivars):
_attributedString NSMutableAttributedString 排版后的富文本
_pageRanges NSArray 每页 NSRange 数组
_highlights NSArray 高亮列表
_underlines NSArray 下划线列表
_reviews NSArray 书评列表
_tempHighlights NSArray 临时高亮
_tempUnderlines NSArray 临时下划线
_layouter WRCoreTextLayouter 排版器引用
_outlineContents NSArray 目录内容
_searchResults NSArray 搜索结果
_bookmarkSet NSSet 书签集合
_originalString NSAttributedString 原始富文本
WRCoreTextLayouter (32 ivars):
_htmlString NSString HTML 源码
_attributedString NSAttributedString 排版后的富文本
_layoutString NSAttributedString 实际排版用的富文本
_book WRBook 书籍引用
_chapter WRChapter 章节引用
_markContentChapter WRMarkContentChapter 标注内容
...
WRCoreTextLayoutFrame (25 ivars):
_attributedString NSAttributedString 排版用富文本
_lines NSMutableArray DTCoreTextLayoutLine 数组
_frame NSString CTFrame 描述
_stringIndices NSArray 字符索引映射
_pageRanges NSArray 页面范围
...
WRPageView (16 ivars):
_pageElements NSArray 页面元素列表
_imageViews NSMutableArray 图片视图缓存
_autoReadTimer NSTimer 自动阅读定时器
_turnPageTimer NSTimer 翻页定时器
_chapterTitle NSString 章节标题
_loadingLabel UILabel 加载提示
_loadingImageView UIImageView 加载图片
_activityIndicator WRActivityIndicator 加载指示器
_errorLabel UILabel 错误提示
_retryButton QMUIButton 重试按钮
_loadingProgress WRLoadingProgressView 加载进度
...
DTHTMLElement (38 ivars):
_textAttachment DTTextAttachment 文本附件
_linkURL NSURL 链接地址
_textColor UIColor 文字颜色
_backgroundColor UIColor 背景色
_tagName NSString 标签名
_children NSArray 子元素
_attributes NSDictionary HTML 属性
_cssStyles NSDictionary CSS 样式
_borderStyle DTBorderStyle 边框样式
_backgroundImage DTBackgroundImageStyle 背景图
_tableStyle DTTableStyle 表格样式
...
```
---
## 二、精简核心算法实现 (Python)
### 2.1 EPUB CSS 级联算法
```python
def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
epub_css: str, user_settings_css: str) -> dict:
"""
5 层 CSS 级联: default → replace → dark → epub内嵌 → 用户设置
后面的层覆盖前面的层中相同选择器+属性的值。
Args:
default_css: Safari 默认样式 (default.css)
replace_css: 读书默认替换 (replace.css)
dark_css: 暗黑主题 (dark.css)
epub_css: EPUB 书籍内嵌 CSS
user_settings_css: 用户自定义设置 (字号/行高/主题)
Returns:
dict: {selector: {property: value}} 合并后的样式表
"""
import re
def parse_css(css_text: str) -> dict:
"""解析 CSS 文本为 {selector: {prop: val}} 字典"""
rules = {}
# 移除注释
css_text = re.sub(r'/\*.*?\*/', '', css_text, flags=re.DOTALL)
# 匹配选择器和声明块
for match in re.finditer(r'([^{}]+)\{([^{}]+)\}', css_text):
selectors = match.group(1).strip()
declarations = match.group(2).strip()
for selector in selectors.split(','):
selector = selector.strip()
if selector not in rules:
rules[selector] = {}
for decl in declarations.split(';'):
decl = decl.strip()
if ':' in decl:
prop, val = decl.split(':', 1)
rules[selector][prop.strip()] = val.strip()
return rules
def merge(base: dict, override: dict) -> dict:
"""合并两个样式表, override 覆盖 base"""
result = {k: dict(v) for k, v in base.items()}
for selector, props in override.items():
if selector not in result:
result[selector] = {}
result[selector].update(props)
return result
# 按优先级逐层合并
result = parse_css(default_css) # 层1: 基础
result = merge(result, parse_css(replace_css)) # 层2: 读书默认
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
return result
```
### 2.2 CoreText 分页算法
```python
def paginate_attributed_string(attr_string_length: int,
page_width: float,
page_height: float,
line_height: float,
line_spacing: float,
paragraph_spacing: float) -> list:
"""
使用 CTTypesetterSuggestLineBreak 逐行排版, 累计高度超过页面高度时换页。
等价于 WRChapterPageCount.recalculatePageRangesForAttributedString
Args:
attr_string_length: 富文本总字符数
page_width: 页面宽度 (pt)
page_height: 页面高度 (pt)
line_height: 行高 (pt)
line_spacing: 行间距 (pt)
paragraph_spacing: 段间距 (pt)
Returns:
list of (start_index, length) 每页的 NSRange
"""
page_ranges = []
current_index = 0
page_start = 0
total_height = 0.0
while current_index < attr_string_length:
# CTTypesetterSuggestLineBreak: 给定当前位置和可用宽度, 返回本行能放多少字符
# 在真实代码中这是 CoreText 的 C 函数调用
line_break_index = suggest_line_break(current_index, page_width)
# 计算本行高度 (包含行间距)
current_line_height = line_height + line_spacing
# 检查是否需要换页
if total_height + current_line_height > page_height:
# 当前页结束
page_ranges.append((page_start, current_index - page_start))
page_start = current_index
total_height = 0.0
total_height += current_line_height
current_index += line_break_index
# 最后一页
if page_start < attr_string_length:
page_ranges.append((page_start, attr_string_length - page_start))
return page_ranges
def suggest_line_break(start_index: int, available_width: float) -> int:
"""
模拟 CTTypesetterSuggestLineBreak
从 start_index 开始, 在 available_width 内能放置的字符数
简化实现: 按空格/标点断行, 累计字符宽度
"""
# 实际实现依赖 CTTypesetter, 这里是逻辑等价
# CoreText 内部会考虑:
# - 字符宽度 (CTFontGetAdvancesForGlyphs)
# - 字间距 (kCTKernAttributeName)
# - 断行机会 (Unicode Line Break Algorithm)
# - 连字符断行 (CTTypesetterSuggestLineBreakWithHyphenation)
pass
```
### 2.3 avoidPageBreakInside 算法
```python
def avoid_page_break_inside(lines: list, page_height: float,
max_lines_to_remove: int = 3) -> list:
"""
如果最后几行被截断, 移除最多 max_lines_to_remove 行以避免内容破碎。
等价于 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
Args:
lines: DTCoreTextLayoutLine 列表, 每个有 height 属性
page_height: 页面可用高度 (pt)
max_lines_to_remove: 最多移除行数 (默认 3)
Returns:
调整后的 lines 列表
"""
if not lines:
return lines
# 计算总内容高度
total_height = sum(line.height for line in lines)
# 如果没有溢出, 无需处理
excess_height = total_height - page_height
if excess_height <= 0:
return lines
# 从最后一行开始, 尝试移除行直到不再溢出
removed_height = 0.0
lines_to_remove = 0
for i in range(len(lines) - 1, -1, -1):
if lines_to_remove >= max_lines_to_remove:
break
removed_height += lines[i].height
lines_to_remove += 1
if removed_height >= excess_height:
break
# 移除最后 N 行
if lines_to_remove > 0:
return lines[:len(lines) - lines_to_remove]
return lines
```
### 2.4 文本选择 Hit Test 算法
```python
def hit_test_for_character_index(touch_point: tuple,
ct_frame_lines: list,
view_height: float) -> int:
"""
将触摸点转换为字符索引。
等价于 WRPageView 中的 CoreText hit test 逻辑
坐标系转换:
UIKit 坐标系: 原点在左上角, Y 轴向下
CoreText 坐标系: 原点在左下角, Y 轴向上
Args:
touch_point: (x, y) UIKit 坐标系下的触摸点
ct_frame_lines: CTLine 列表, 每个有 origin 和 string_range
view_height: 视图高度 (用于坐标翻转)
Returns:
字符在 NSAttributedString 中的索引, -1 表示未命中
"""
touch_x, touch_y = touch_point
# 坐标翻转: UIKit → CoreText
ct_y = view_height - touch_y
for line_info in ct_frame_lines:
line_origin = line_info['origin'] # (x, y) CoreText 坐标
line_height = line_info['height']
line_ascent = line_info['ascent']
# 检查 Y 坐标是否在本行范围内
# CoreText 的 origin 是基线位置
line_top = line_origin[1] + line_ascent
line_bottom = line_origin[1] - (line_height - line_ascent)
if line_bottom <= ct_y <= line_top:
# 检查 X 坐标
line_width = line_info['width']
if line_origin[0] <= touch_x <= line_origin[0] + line_width:
# CTLineGetStringIndexForPosition 的等价逻辑
# 遍历本行的 glyph run, 找到包含触摸点的字符
relative_x = touch_x - line_origin[0]
char_index = line_info['string_range'][0]
# 遍历 run 中的每个 glyph, 累计宽度
accumulated_width = 0.0
for glyph_info in line_info['glyphs']:
accumulated_width += glyph_info['advance']
if accumulated_width >= relative_x:
return char_index
char_index += 1
return line_info['string_range'][0] + line_info['string_range'][1] - 1
return -1 # 未命中任何行
```
### 2.5 DRM 解密算法
```python
import hashlib
from Crypto.Cipher import AES
def decrypt_chapter(encrypted_data: bytes, book_id: str) -> bytes:
"""
AES-128-CBC 解密 EPUB 章节文件。
等价于 WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost:
Args:
encrypted_data: 加密的文件数据
book_id: 书籍 ID (用于派生密钥)
Returns:
解密后的明文数据 (XHTML)
"""
# 检查自定义头部标识 "REWD"
if encrypted_data[:4] == b'REWD':
# 自定义加密格式
header_size = int.from_bytes(encrypted_data[4:8], 'little')
iv = encrypted_data[8:24] # 16 字节 IV
ciphertext = encrypted_data[24 + header_size:]
else:
# 标准 AES-128-CBC
iv = encrypted_data[:16] # 前 16 字节为 IV
ciphertext = encrypted_data[16:]
# 密钥派生: book_id → MD5 → 取前 16 字节
key = hashlib.md5(book_id.encode('utf-8')).digest()[:16]
# AES-128-CBC 解密, PKCS7 填充
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
# 移除 PKCS7 填充
pad_len = plaintext[-1]
if 1 <= pad_len <= 16:
plaintext = plaintext[:-pad_len]
return plaintext
def encrypt_for_local_storage(plaintext: bytes, book_id: str,
original_key: bytes) -> bytes:
"""
本地二次加密存储 (DRM 保护)。
等价于 WREncryptedFileManager.encryptFileForBookId:
originalEncryptKey:atPath:toPath:
防止直接拷贝文件读取。
"""
# 生成本地存储密钥 (与原始密钥不同)
local_key = hashlib.md5((book_id + "local").encode('utf-8')).digest()[:16]
# 生成随机 IV
iv = os.urandom(16)
# AES-128-CBC 加密
# PKCS7 填充
pad_len = 16 - (len(plaintext) % 16)
plaintext += bytes([pad_len] * pad_len)
cipher = AES.new(local_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(plaintext)
# 输出: "REWD" 标识 + IV + 密文
return b'REWD' + iv + ciphertext
```
### 2.6 Glyph Path 生成算法
```python
def create_glyph_path(ct_run, ct_line_origin: tuple) -> list:
"""
从 CTRun 提取字形并生成 CGPath。
等价于 DTCoreTextGlyphRun.newPathWithGlyphs
Args:
ct_run: CTRun 对象
ct_line_origin: CTLine 原点 (x, y)
Returns:
list of CGPath 元素 (moveto/lineto/curveto/close)
"""
# 获取字形信息
glyph_count = CTRunGetGlyphCount(ct_run)
glyphs = (CGGlyph * glyph_count)()
positions = (CGPoint * glyph_count)()
CTRunGetGlyphs(ct_run, CFRangeMake(0, glyph_count), glyphs)
CTRunGetPositions(ct_run, CFRangeMake(0, glyph_count), positions)
# 获取字体
attributes = CTRunGetAttributes(ct_run)
ct_font = CFDictionaryGetValue(attributes, kCTFontAttributeName)
path_elements = []
for i in range(glyph_count):
glyph = glyphs[i]
position = positions[i]
# 转换为绝对坐标 (加上 CTLine 原点)
abs_x = ct_line_origin[0] + position.x
abs_y = ct_line_origin[1] + position.y
# CTFontCreatePathForGlyph: 获取单个字形的轮廓路径
glyph_path = CTFontCreatePathForGlyph(ct_font, glyph, None)
if glyph_path:
# 将字形路径平移到正确位置
transform = CGAffineTransformMakeTranslation(abs_x, abs_y)
transformed_path = CGPathCreateMutableCopyByTransformingPath(
glyph_path, transform
)
path_elements.append(transformed_path)
return path_elements
```
### 2.7 繁简转换算法
```python
def convert_hans_to_hant(attributed_string: str) -> str:
"""
简体中文 → 繁体中文转换。
等价于 WRCoreTextLayouter.convertHansToHantWithAttributedString:
两步法: 简体 → Latin 桥接 → 繁体
(避免 OpenCC 等库的直接转换可能丢失格式)
Args:
attributed_string: 包含简体中文的富文本
Returns:
转换为繁体的富文本
"""
# 步骤1: 使用 ICU 或 OpenCC 进行简→繁映射
# ICU: ubrk_open(UBRK_CHARACTER, "zh-Hant", ...)
# OpenCC: opencc_convert("s2t", text)
# 简化实现: 使用 Unicode 码点映射表
# 读书内部可能使用腾讯自研的繁简转换库
conversion_table = load_hans_to_hant_table() # ~8000 个映射
result = []
for char in attributed_string:
if char in conversion_table:
result.append(conversion_table[char])
else:
result.append(char)
return ''.join(result)
```
### 2.8 预加载预测算法
```python
def should_preload_chapters(book_rank: int, archive_rank: int,
user_reading_speed: float,
current_chapter_idx: int,
total_chapters: int) -> list:
"""
预测需要预加载的章节列表。
等价于 WRForecastUtils.shouldPreloadChapterUidsForBook:
type:bookRank:archiveRank:
Args:
book_rank: 书籍热度排名
archive_rank: 用户书架排名
user_reading_speed: 用户阅读速度 (章节/小时)
current_chapter_idx: 当前章节索引
total_chapters: 总章节数
Returns:
list of chapter_uid 需要预加载的章节 UID 列表
"""
preload_chapters = []
# 策略1: 总是预加载下一章
if current_chapter_idx + 1 < total_chapters:
preload_chapters.append(current_chapter_idx + 1)
# 策略2: 热门书籍预加载更多 (前 1000 名)
if book_rank < 1000:
# 预加载后 3 章
for i in range(2, 4):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 策略3: 用户书架排名靠前的书预加载更多
if archive_rank < 10:
# 预加载后 5 章
for i in range(1, 6):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 策略4: 快速阅读者预加载更多
if user_reading_speed > 5.0: # 每小时 >5 章
for i in range(1, 8):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 去重并排序
return sorted(set(preload_chapters))
```
### 2.9 分页范围计算 (二分查找)
```python
def page_index_for_character_index(char_index: int,
page_ranges: list) -> int:
"""
二分查找字符索引所在的页码。
等价于 WRChapterPageCount 中的页码查找逻辑
Args:
char_index: 字符在 NSAttributedString 中的索引
page_ranges: [(start, length), ...] 每页的 NSRange
Returns:
页码 (0-based), -1 表示未找到
"""
if not page_ranges:
return -1
left, right = 0, len(page_ranges) - 1
while left <= right:
mid = (left + right) // 2
start, length = page_ranges[mid]
end = start + length
if char_index < start:
right = mid - 1
elif char_index >= end:
left = mid + 1
else:
return mid
# 边界情况: 在最后一页之后
return len(page_ranges) - 1
```
### 2.10 翻页控制器故障检测
```python
def detect_navigation_direction_crash(page_view_controller,
queuing_scroll_view,
is_animated: bool) -> bool:
"""
检测 UIPageViewController 的 QueuingScrollView 导航方向崩溃。
等价于 WRPageViewController.detectNavigationDirectionCrashWithPageViewController:
queuingScrollView:inMethodDidScrollWithAnimation:force:logDetail:logCrashReason:
已知 bug: QueuingScrollView 在快速连续翻页时可能崩溃
原因: 内部 child view controller 管理状态不一致
Returns:
True 表示检测到即将崩溃, 需要修复
"""
# 检查1: scroll view content offset 是否越界
content_offset = queuing_scroll_view.contentOffset
content_size = queuing_scroll_view.contentSize
bounds_size = queuing_scroll_view.bounds.size
if content_offset.x < -bounds_size.width:
return True # 向左越界
if content_offset.x > content_size.width:
return True # 向右越界
# 检查2: child view controller 数量是否异常
child_vcs = page_view_controller.childViewControllers
if len(child_vcs) > 3:
return True # UIPageViewController 最多应该有 3 个 child
# 检查3: 当前 page 是否为空
if page_view_controller.viewControllers is None:
return True
# 检查4: delegate 一致性
if page_view_controller.delegate is None:
return True
return False
```