780 lines
30 KiB
Markdown
780 lines
30 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
|
|
```
|