ReadViewSDK/Doc/WXRead/analysis/02_符号恢复与核心算法.md
2026-05-21 19:40:51 +08:00

30 KiB

微信读书 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 级联算法

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 分页算法

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 算法

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 算法

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 解密算法

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 生成算法

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 繁简转换算法

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 预加载预测算法

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 分页范围计算 (二分查找)

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 翻页控制器故障检测

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