feat(reader): 增强阅读器功能与 UI 测试支持

- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
This commit is contained in:
shen 2026-05-31 23:56:54 +08:00
parent 44202357c0
commit 1efb9d172f
268 changed files with 4991 additions and 44 deletions

View File

@ -0,0 +1,415 @@
# ReadViewSDK UI 自动化测试可执行方案
## 1. 目标与边界
本文档描述当前架构下可落地的 XCUITest 方案。目标不是一次性覆盖所有 SDK API而是先为 Demo App 的核心阅读链路建立稳定回归网:
- 书库页面能展示样本书。
- 样本书能打开阅读器。
- 点击阅读区域中部能显示顶部和底部工具栏。
- 顶部返回按钮能关闭阅读器并回到书库。
- 设置面板能打开、操作、关闭。
- 三种翻页模式能通过启动参数切换并保持阅读器可用。
XCUITest 运行在独立进程,不能直接调用 `RDEPUBReaderController` 的 Swift 公共 API。因此需要通过 UI 元素、launch arguments、Demo 测试状态标签或截图附件验证结果。SDK 公共 API 可以由单元测试或 Demo 自动化入口间接驱动,不应写成 XCUITest 的直接依赖。
## 2. 当前可用基础
### 2.1 已有 accessibilityIdentifier
| 标识符 | 当前文件 | 用途 |
|--------|----------|------|
| `epub.reader.back` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部返回按钮 |
| `epub.reader.bookmark` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部书签按钮 |
| `epub.reader.title` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部标题 |
| `epub.reader.toc` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 目录按钮 |
| `epub.reader.bookmarks` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 书签列表按钮 |
| `epub.reader.highlights` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 高亮列表按钮 |
| `epub.reader.add-highlight` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 新建高亮按钮 |
| `epub.reader.settings` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 设置按钮 |
| `demo.root` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 根视图 |
| `demo.status` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 状态标签 |
| `demo.books.table` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库表格 |
| `demo.books.empty` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 空书库提示 |
| `demo.book.{n}` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库第 n 本书 |
| `demo.reader.host` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | modal 场景下的阅读器宿主 |
### 2.2 已有 Demo 启动参数
`ReadViewDemo/ReadViewDemo/ViewController.swift` 已有 `LaunchAutomationPlan`
```bash
--demo-book-title <关键词>
--demo-display-type <pagecurl|scroll|vertical>
--demo-page <页码>
--demo-display-sequence <pagecurl,scroll,vertical>
--demo-step-delay <>
```
首批测试优先使用 `--demo-book-title 回归验证样本`。该样本目前存在于 `ReadViewDemo/ReadViewDemo/book/回归验证样本.txt`,适合作为稳定自动化入口。
## 3. 当前架构下的落点
不要把测试辅助职责重新塞回 `RDEPUBReaderController.swift`。identifier 和测试状态应按真实 UI/职责归属放置:
| 能力 | 落点 | 原因 |
|------|------|------|
| 顶部工具栏容器 | `RDEPUBReaderTopToolView.swift` | 工具栏自身创建和维护顶部按钮 |
| 底部工具栏容器 | `RDEPUBReaderBottomToolView.swift` | 工具栏自身创建和维护底部按钮 |
| 阅读点击区域 | `RDReaderView.swift` | 点击中区、翻页手势都由 ReaderView 处理 |
| 滚动翻页容器 | `RDReaderView.swift``collectionView` | 横滑/竖滑模式使用 collection view |
| 单页内容 cell | `RDReaderContentCell.swift` 或当前分页 cell 文件 | 验证 cell 存在,不验证文本排版细节 |
| 设置面板控件 | `EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift` | 设置面板已从 EPUBUI 根目录迁移到 Settings |
| 目录列表 | `RDEPUBReaderChapterListController.swift` | 目录是独立 UITableViewController |
| 高亮/书签列表 | `RDEPUBReaderHighlightsViewController.swift` | 高亮和书签管理器在该文件中 |
| Demo 自动化状态 | `ViewController.swift``RDURLReaderController.swift` | XCUITest 通过文本/identifier 读取状态 |
## 4. 需要新增的最小标识符
### 4.1 P0 必需
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.topToolbar` | `RDEPUBReaderTopToolView.swift` | 断言顶部工具栏显示/隐藏 |
| `epub.reader.bottomToolbar` | `RDEPUBReaderBottomToolView.swift` | 断言底部工具栏显示/隐藏 |
| `epub.reader.content` | `RDReaderView.swift` | 点击阅读区域中部、滑动翻页 |
| `epub.reader.paging` | `RDReaderView.swift``collectionView` | 横滑/竖滑容器存在性 |
| `epub.reader.settings.scroll` | `RDEPUBReaderSettingsViewController.swift` | 设置面板已打开 |
| `epub.reader.settings.brightness` | `RDEPUBReaderSettingsViewController.swift` | 亮度 slider |
| `epub.reader.settings.font.decrease` | `RDEPUBReaderSettingsViewController.swift` | 字号减 |
| `epub.reader.settings.font.increase` | `RDEPUBReaderSettingsViewController.swift` | 字号加 |
| `epub.reader.settings.font.value` | `RDEPUBReaderSettingsViewController.swift` | 字号值 |
| `epub.reader.settings.displayType` | `RDEPUBReaderSettingsViewController.swift` | 翻页模式分段控件 |
| `epub.reader.settings.done` | `RDEPUBReaderSettingsViewController.swift` | 完成按钮 |
| `demo.reader.state` | Demo 层 | 输出当前打开状态、页码、翻页模式 |
### 4.2 P1 后续补充
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.toc.list` | `RDEPUBReaderChapterListController.swift` | 目录列表 |
| `epub.reader.toc.cell.{n}` | `RDEPUBReaderChapterListController.swift` | 目录项 |
| `epub.reader.bookmark.list` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签列表 |
| `epub.reader.bookmark.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签项 |
| `epub.reader.highlight.list` | `RDEPUBReaderHighlightsViewController.swift` | 高亮列表 |
| `epub.reader.highlight.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` | 高亮项 |
| `epub.reader.settings.lineHeight` | `RDEPUBReaderSettingsViewController.swift` | 行距分段控件 |
| `epub.reader.settings.columns` | `RDEPUBReaderSettingsViewController.swift` | 栏数分段控件 |
| `epub.reader.settings.theme.{n}` | `RDEPUBReaderSettingsViewController.swift` | 主题按钮 |
`epub.reader.pageIndicator` 暂不列为必需项,因为当前没有稳定的页码指示器 UI。若需要断言页码优先通过 `demo.reader.state` 暴露 `page=...`,或者给 `RDReaderView` 设置 `accessibilityValue`
## 5. Demo 测试状态设计
建议增加一个仅用于自动化的状态标签:
```swift
stateLabel.accessibilityIdentifier = "demo.reader.state"
stateLabel.isHidden = true
stateLabel.text = "reader=opened page=1 display=pagecurl toolbar=hidden"
```
状态来源可以在 Demo 层更新,不要求 SDK 为测试暴露内部对象:
- 打开阅读器后:`reader=opened`
- 返回书库后:`reader=closed`
- 跳页或翻页后:`page=<n>`
- 切换翻页模式后:`display=pagecurl|scroll|vertical`
- 工具栏显示后:`toolbar=visible`
这个标签能显著减少 XCUITest 对动画、布局和截图的猜测,是当前架构下最稳的可执行方案。
## 6. UI Test Target 创建方式
使用 workspace不使用单独的 xcodeproj
1. 打开 `ReadViewDemo/ReadViewDemo.xcworkspace`
2. File -> New -> Target -> iOS UI Testing Bundle
3. Product Name: `ReadViewDemoUITests`
4. Target to be Tested: `ReadViewDemo`
5. 新增测试目录:
```text
ReadViewDemo/
ReadViewDemoUITests/
Helpers/
AccessibilityIdentifiers.swift
XCUIApplication+Launch.swift
XCUIElement+Wait.swift
ReaderUITests/
BookListTests.swift
ReaderOpenCloseTests.swift
ReaderToolbarTests.swift
SettingsPanelTests.swift
DisplayTypeTests.swift
SmokeScreenshotTests.swift
```
首批不要拆太多测试类,避免还没稳定就出现维护成本。
## 7. P0 测试用例
### 7.1 启动辅助
```swift
import XCTest
enum IDs {
static let demoStatus = "demo.status"
static let demoBooksTable = "demo.books.table"
static func demoBook(_ n: Int) -> String { "demo.book.\(n)" }
static let demoReaderState = "demo.reader.state"
static let readerBack = "epub.reader.back"
static let readerTitle = "epub.reader.title"
static let readerTopToolbar = "epub.reader.topToolbar"
static let readerBottomToolbar = "epub.reader.bottomToolbar"
static let readerContent = "epub.reader.content"
static let readerSettings = "epub.reader.settings"
static let settingsScroll = "epub.reader.settings.scroll"
static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsDone = "epub.reader.settings.done"
}
extension XCUIApplication {
func launchAndOpenSampleBook(
displayType: String? = nil,
pageNumber: Int? = nil
) {
var args = ["--demo-book-title", "回归验证样本"]
if let displayType {
args += ["--demo-display-type", displayType]
}
if let pageNumber {
args += ["--demo-page", "\(pageNumber)"]
}
launchArguments = args
launch()
}
@discardableResult
func waitForReader(timeout: TimeInterval = 10) -> XCUIElement {
let backButton = buttons[IDs.readerBack]
XCTAssertTrue(backButton.waitForExistence(timeout: timeout))
return backButton
}
}
```
### 7.2 书库与打开关闭
```swift
final class ReaderOpenCloseTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBookListShowsSampleBooks() {
app.launch()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
XCTAssertTrue(app.cells[IDs.demoBook(0)].waitForExistence(timeout: 5))
}
func testLaunchArgumentOpensReader() {
app.launchAndOpenSampleBook()
app.waitForReader()
XCTAssertTrue(app.staticTexts[IDs.readerTitle].exists)
}
func testBackButtonReturnsToBookList() {
app.launchAndOpenSampleBook()
app.waitForReader().tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
}
}
```
### 7.3 工具栏显示
```swift
final class ReaderToolbarTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTapCenterShowsToolbars() {
app.launchAndOpenSampleBook()
app.waitForReader()
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5))
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3))
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3))
}
}
```
### 7.4 设置面板
```swift
final class SettingsPanelTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenChangeFontAndCloseSettings() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
}
```
### 7.5 翻页模式 Smoke Test
```swift
final class DisplayTypeTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenWithPageCurl() {
app.launchAndOpenSampleBook(displayType: "pagecurl")
app.waitForReader()
}
func testOpenWithHorizontalScroll() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
}
func testOpenWithVerticalScroll() {
app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader()
}
}
```
## 8. P1 测试用例
P0 稳定后再加入:
- 目录打开、目录列表存在、点击第一项返回阅读器。
- 添加书签后按钮状态变化,打开书签列表存在对应 cell。
- 设置面板切换行距、栏数、主题后阅读器仍可用。
- 横滑/竖滑模式下拖动内容区域后 `demo.reader.state` 的 page 变化。
- 高亮列表打开和空态显示。
搜索和文本选择高亮不建议作为第一批 UI 自动化。它们依赖文本命中、长按选择、浮层菜单和异步渲染flaky 风险更高。可以先用单元测试覆盖搜索引擎,用 P1/P2 再补 UI 冒烟。
## 9. CI 集成
### 9.1 手动命令
```bash
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:ReadViewDemoUITests
```
如果本机没有 `iPhone 16`,先查看可用模拟器:
```bash
xcrun simctl list devices available
```
### 9.2 GitHub Actions
```yaml
name: UI Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
jobs:
ui-tests:
runs-on: macos-15
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- name: Install Pods
run: cd ReadViewDemo && pod install
- name: Run UI Tests
run: |
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-only-testing:ReadViewDemoUITests \
-resultBundlePath ui-test-results.xcresult
- name: Upload xcresult
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: ui-test-results.xcresult
```
CI 首批只跑 P0。P1 可以先本地或 nightly 跑,稳定后再进入 PR 必过。
## 10. 实施顺序
| 阶段 | 内容 | 验收标准 |
|------|------|----------|
| 1 | 补 P0 identifiers | XCUITest 能定位内容区、工具栏、设置控件 |
| 2 | 增加 `demo.reader.state` | 能通过 hidden label 读到 opened/page/display/toolbar |
| 3 | 创建 `ReadViewDemoUITests` | `xcodebuild test` 能发现测试 target |
| 4 | 实现 P0 用例 | 本地模拟器连续跑 3 次通过 |
| 5 | 接入 CI | PR 中 P0 UI Tests 可运行并上传 xcresult |
| 6 | 扩展 P1 | 目录、书签、主题、翻页断言逐步加入 |
## 11. 工时预估
| 工作 | 预估 |
|------|------|
| P0 identifiers + Demo 状态标签 | 2-3h |
| UI Test Target + helpers | 1-2h |
| P0 测试实现与稳定性调整 | 4-6h |
| CI 接入 | 1-2h |
| P1 首批扩展 | 4-8h |
首个可用版本建议按 1.5 到 2 天排期。后续每加入一组复杂交互,先本地观察稳定性,再进入 CI 必过集合。
## 12. 风险与约束
- UI 自动化不能依赖固定动画时间,优先使用 `waitForExistence` 和状态标签。
- 不要用截图像素对比作为 PR 必过项;截图附件适合作为人工排查材料。
- 样本书必须稳定存在,优先使用 `回归验证样本.txt`
- 工具栏默认可能隐藏,测试应先点击阅读区域中部再断言底部按钮。
- 设置面板、目录面板是导航/弹出结构,测试应等待面板根控件,而不是立即点击内部控件。
- 若未来 Reader UI 迁移到真实 AppDemo-only 的 `demo.reader.state` 不应进入 SDK 公共 API。

View File

@ -1,10 +0,0 @@
# WXRead 剩余问题修复计划
> 本文档内容已整合进入 [架构对比分析_WXRead_vs_ReadViewSDK.md](/Users/shen/Work/Code/ReadViewSDK/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md)。
> 后续请只维护对比文档中的以下部分:
>
> - `核查摘要`
> - `缺失能力清单`
> - `收口路线(整合原修复计划)`
保留本文件仅为兼容旧引用,避免计划与现状继续出现双份口径。

View File

@ -23,6 +23,12 @@
|------|------|
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案CSS 分层、类型器升级) |
## 测试与质量
| 文档 | 说明 |
|------|------|
| [UI自动化测试工作文档.md](UI自动化测试工作文档.md) | XCUITest UI 自动化测试完整工作计划现状分析、accessibilityIdentifier 补充清单、UI Test Target 创建、测试用例设计(书库/阅读器/工具栏/设置/书签/高亮/目录/截图、CI 集成方案、实施检查清单 |
## 目录约定
- `Doc/`:项目级文档(架构、规范、维护指南、模块实现逻辑)

View File

@ -0,0 +1,538 @@
# 阅读器功能开发计划
基于 [阅读器规划.md](阅读器规划.md) 中的三方对比,本文档给出阅读器功能的开发计划与当前落地状态。
功能完整度部分书架、批注导出、阅读统计、TTS、全局搜索、云端同步、夜间定时暂不展开。
---
## 一、渲染质量
### 1. 字体选择器
**当前状态**:✅ 基础能力已完成。
已支持系统、宋体、圆体、等宽四档字体选择;设置项可持久化;切换字体会触发重新分页;分页缓存签名已包含字体信息,避免不同字体复用旧缓存。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `RDEPUBReaderFontChoice`,配置增加 `fontChoice` | 已完成 |
| `RDEPUBReaderSettings.swift` | 新增 `fontChoice` 持久化,更新 `applying(to:)``capture(configuration:brightness:)` | 已完成 |
| `RDEPUBReaderContext.swift` | `currentTextRenderStyle()` 改用 `configuration.fontChoice.font(ofSize:)` | 已完成 |
| `RDURLReaderController.swift` | URL 阅读器同步使用 `fontChoice` 生成文字样式 | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | `requiresRepagination(from:to:)` 增加 `fontChoice` 变更检查 | 已完成 |
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存签名增加字体名 | 已完成 |
| `RDEPUBReaderSettingsViewController.swift` | 新增 `onFontChoiceChange` 回调、字体分段控件和 accessibility identifier | 已完成 |
| `RDEPUBReaderChromeCoordinator.swift` | 设置面板接线字体变更回调 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖字体选择 | 已完成 |
**当前验收结果**
- ✅ 切换字体后触发重排。
- ✅ 字体选择可随阅读器设置持久化。
- ✅ 分页缓存按字体隔离。
- ✅ UI 自动化已覆盖设置面板字体选择。
**后续增强计划**
1. 引入更多内置字体包建议思源宋体、思源黑体、方正书宋、方正兰亭黑、Lora、OpenDyslexic
2. 使用 `CTFontManagerRegisterFontsForURL` 注册 bundle 字体。
3. 字体列表从枚举升级为资源驱动模型,每项包含 `displayName`、`fontName`、`previewText`、可用性状态。
4. 字体选择 UI 从分段控件升级为横向预览卡片,展示真实字体效果。
5. 增加字体资源加载失败兜底,失败时回退系统字体并保留用户设置。
---
### 2. 暗色模式图片处理
**当前状态**:✅ 基础能力已完成。
暗色主题下会对正文图片做显示副本调暗,降低白底图片在深色背景上的刺眼程度。处理只作用于展示层,不修改分页原始内容;封面和小图会跳过,避免误处理封面、图标和装饰图。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `darkImageAdjustmentEnabled``darkImageBlendRatio` | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | 主题和暗色图片配置变更触发可见页刷新 | 已完成 |
| `RDEPUBTextContentView.swift` | DTCoreText 展示内容增加暗色图片显示副本处理 | 已完成 |
| `RDEPUBTextContentView.swift` | 增加暗色图片缓存,避免重复生成 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖暗色主题切换 | 已完成 |
**当前实现策略**
- 仅当背景亮度低于阈值、配置开启、混合比例大于 0 时启用。
- 仅处理正文 inline image attachment。
- 跳过封面和小于 80x80 的图片。
- 使用 `NSCache` 缓存处理后的图片。
- 使用主题背景色按比例覆盖原图,透明区域保持透明。
**当前验收结果**
- ✅ 暗色模式下图片不再完全以原始亮色块直出。
- ✅ 图片内容仍保持可辨认,不做反色。
- ✅ 处理结果有缓存。
- ✅ UI 自动化已覆盖暗色主题入口。
**后续增强计划**
1. 增加真实 EPUB 图片样本的截图回归测试。
2. 按图片平均亮度自适应 `darkImageBlendRatio`
3. 增加用户侧开关,允许关闭暗色图片处理。
4. 如果后续恢复 WebView 路径,再补充 CSS filter 策略。
---
### 3. 简繁转换
**当前状态**:零实现。语言元数据已提取(`publication.metadata.language`),但仅用于拉丁/CJK CSS 分轨。
**WXRead 实现参考**
- `WREpubTypesetter` 检测 `book.language``Hant`/`TW`/`HK` 时调用 `_WRConvertHansToHantIfNeeded()`
- 使用 `CFStringTransform` 两步转换Hans → Latin → Hant
**实施步骤**
**Step 1转换工具**
1. 新增 `RDEPUBTextChineseConverter.swift`,提供:
```swift
enum RDEPUBChineseScript { case hans, hant }
func convert(_ text: String, to script: RDEPUBChineseScript) -> String
```
2. 实现使用 `CFStringTransform`
```swift
let mutable = NSMutableString(string: text) as CFMutableString
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) // Hans → Pinyin
CFStringTransform(mutable, nil, kCFStringTransformStripDiacritics, false) // Pinyin → stripped
// 然后通过字典映射到繁体
```
或直接使用 Apple 的 `kCFStringTransformHansToHant`(如果可用)。
**Step 2判断是否需要转换**
3. 在 `RDEPUBTextBookBuilder.build()``RDEPUBTextRendererSupport.makeChapterRenderRequest()` 中:
- 读取 `publication.metadata.language`
- 如果语言为 `zh-Hant`/`zh-TW`/`zh-HK` 且用户设置为简体,或语言为 `zh-Hans`/`zh-CN` 且用户设置为繁体,触发转换
4. 新增 `RDEPUBReaderConfiguration.chineseScript: RDEPUBChineseScript?`nil = 跟随书籍)
**Step 3应用转换**
5. 在 `normalizeReadingAttributes(in:style:)` 之后、返回 `RDEPUBTextChapterRenderRequest` 之前,对 attributed string 的文本内容执行转换
6. 需要保持 attributed string 的属性(字体、颜色、附件)不变,只替换字符
**验收标准**
- 简体 EPUB 在繁体模式下显示繁体
- 繁体 EPUB 在简体模式下显示简体
- 转换不影响分页结果(转换后字符数可能变化,需验证)
- 高亮、搜索功能在转换后仍然正常
---
### 4. 孤行/寡行控制
**当前状态**`RDEPUBTextLayoutConfig` 声明了 `avoidOrphans`/`avoidWidows`(默认 true`RDEPUBTextLayouter` 完全不读取这两个标记。
**实现原理**
- **寡行widow**:段落最后一行单独出现在页底 → 应将该行拉到下一页
- **孤行orphan**:段落第一行单独出现在页首 → 应将前一页最后一行拉过来
- 通常只处理寡行(保留至少 2 行在页底),孤行处理会引发连锁重排
**实施步骤**
**Step 1CoreText 路径**
1. 在 `RDEPUBTextLayouter.layoutFramesUsingCoreText()` 的分页循环中(~行 63-137`adjustedRange` 之后、追加 frame 之前,增加寡行检查:
```swift
if config.avoidWidows {
finalRange = trimmedRangeForAvoidWidows(
from: ctFrame,
proposed: finalRange,
lineRanges: lineRanges
)
}
```
2. `trimmedRangeForAvoidWidows` 实现:
- 从 proposed range 的最后一行向前检查
- 如果最后一行是一个段落的最后一行,且该段落在此页只有 1 行 → 回退到上一个段落边界
- 最多移除 2 行(避免过度收缩)
**Step 2DTCoreText 路径**
3. 在 `layoutFramesUsingDTCoreText()` 中增加相同逻辑
**Step 3段落边界检测**
4. 利用已有的 `paragraphRange(containing:)` 方法(`NSString.paragraphRange`)获取段落范围
5. 对比当前页最后一行的 range 和段落的 range判断是否为段落唯一一行
**验收标准**
- 分页页数可能轻微增加1-3 页),但排版质量提升
- 已知的寡行问题页在修复后不再出现段落最后一行孤悬页底
- `avoidOrphans`/`avoidWidows = false` 时行为不变
---
### 5. Hyphenation 断字
**当前状态**`hyphenation: Bool = true` 配置标记已声明,但 `NSParagraphStyle.hyphenationFactor` 从未设置。CoreText 路径更不支持断字。
**技术约束**
- `NSParagraphStyle.hyphenationFactor``UITextView`(降级路径)有效
- CoreText 直绘路径需要设置 `kCTParagraphStyleSpecifierHyphenationFactor`
- 中文场景断字影响较小(中文无空格分词),主要改善西文排版
**实施步骤**
**Step 1UITextView 降级路径**
1. 在 `RDEPUBTextRendererSupport.paragraphStyle(lineSpacing:)`(行 929-934中增加
```swift
style.hyphenationFactor = 1.0
```
2. 在 `normalizeReadingAttributes()`(行 137-143对已有的 `NSMutableParagraphStyle` 增加:
```swift
paragraphStyle.hyphenationFactor = 1.0
```
**Step 2CoreText 直绘路径**
3. 在 `RDEPUBTextLayouter` 的 frame 构造中,对 `CTParagraphStyle` 增加 hyphenation factor
```swift
var hyphenationFactor: Float = 1.0
let settings = [CTParagraphStyleSetting(
spec: .hyphenationFactor,
valueSize: MemoryLayout<Float>.size,
value: &hyphenationFactor
)]
let paragraphStyle = CTParagraphStyleCreate(settings, settings.count)
```
4. 将此 paragraph style 设置到 attributed string 的段落属性上
**Step 3条件控制**
5. 读取 `config.hyphenation` 标记,为 false 时跳过上述设置
**验收标准**
- 西文长单词在行末正确断字(显示连字符)
- 中文排版不受影响
- `hyphenation = false` 时回到当前行为
---
## 二、工程成熟度
### 6. 自动化测试
**当前状态**:✅ UI 自动化测试基础设施已完成,单元测试和分页回归基准待补。
已在 Demo 工程中接入 `ReadViewDemoUITests`,覆盖打开书籍、阅读器基础交互、顶部/底部工具栏、设置面板、字体选择和暗色主题切换。
**已落地内容**
| 项 | 状态 |
| --- | --- |
| UI Test target `ReadViewDemoUITests` | ✅ 已完成 |
| Demo 自动打开测试 EPUB 的入口 | ✅ 已完成 |
| Accessibility identifier 体系 | ✅ 已完成 |
| 阅读器基础打开测试 | ✅ 已完成 |
| 顶部/底部工具栏测试 | ✅ 已完成 |
| 设置面板测试 | ✅ 已完成 |
| 字体选择 UI 测试 | ✅ 已完成 |
| 暗色主题切换 UI 测试 | ✅ 已完成 |
**当前验证结果**
```text
xcodebuild build: BUILD SUCCEEDED
SettingsPanelTests: TEST SUCCEEDED
ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
```
**下一步实施步骤**
**Step 1单元测试基础设施**
1. 在 `ReadViewDemo.xcodeproj` 中新增 Unit Test target `ReadViewSDKTests`
2. 创建 `Tests/` 目录,配置 `import RDReaderView` 或通过 Demo target 暴露内部测试入口
3. 新增 `.xctestplan` 配置,把 UI 测试和单元测试拆分为不同测试组
**Step 2分页回归测试最高优先级**
4. 新增 `RDEPUBPaginationRegressionTests.swift`,固定 6 类样本章节:
- 纯正文章节
- 图 + 图注 + 正文章节
- 脚注小图标章节
- 多段标题章节
- 大图跨页章节
- 长段落连续章节
5. 每个样本:构建 `RDEPUBTextBook`,断言总页数不变、已知页的 `contentRange` 不变
6. 使用 golden file 对比 `RDEPUBTextChapterPaginationDiagnostic` 输出
**Step 3位置映射测试**
7. 新增 `RDEPUBLocationMappingTests.swift`
- `location → pageNumber` 正向映射
- `pageNumber → location` 逆向映射
- 往返一致性断言
**Step 4渲染管线测试**
8. 新增 `RDEPUBTypesetterTests.swift`
- `normalizeHTML` 输入输出对比
- fragment marker 注入/提取一致性
- 语义标记注入后 `rdPage*` 属性计数不变
**Step 5CI可选**
9. 配置 GitHub Actions 或 Jenkins在 PR 时自动运行测试
**验收标准**
- UI 自动化全量测试稳定通过
- 6 个分页基准样本的页数和 contentRange 稳定
- 位置映射往返测试通过
- 新 PR 触发测试自动运行
---
### 7. 性能基线
**当前状态**`RDEPUBTextPerformanceSampler` 用 `CFAbsoluteTimeGetCurrent()` 采样,结果仅 `print()` 输出。无 Instruments 可视化、无内存监控、无阈值告警。
**实施步骤**
**Step 1os_signpost 集成**
1. 在 `RDEPUBTextPerformanceSampler` 中引入 `os.signpost`
```swift
import os.signpost
let log = OSLog(subsystem: "com.rdreader", category: .pointsOfInterest)
```
2. 在 build/render/paginate 各阶段插入 `os_signpost(.begin, ...)` / `os_signpost(.end, ...)`
3. 这样 Instruments 的 Points of Interest 能直接可视化各阶段耗时
**Step 2内存监控**
4. 新增 `RDEPUBMemoryMonitor`
```swift
func currentMemoryFootprint() -> UInt64 // task_info.resident_size
```
5. 在 `RDEPUBTextBookBuilder.build()` 的每章循环中记录内存峰值
6. 在 `RDEPUBTextPerformanceSample` 中增加 `peakMemoryBytes: UInt64`
**Step 3阈值告警**
7. 定义基准值:首屏 < 2s单章渲染 < 500ms全书构建 < 30s按书的大小可调
8. 超出阈值时通过 `os_log(.error, ...)` 输出警告
9. 在 Demo 中展示性能摘要面板
**Step 4持久化**
10. 性能样本可选持久化到文件,供回归对比
**验收标准**
- Instruments 能看到各阶段的 signpost 区间
- 内存峰值在大书79MB 样本)构建过程中有记录
- 超出阈值时有日志输出
---
### 8. 崩溃防护
**当前状态**`RDReaderView` 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获。
**实施步骤**
**Step 1NSException 捕获**
1. 新增 `RDEPUBCrashGuard` 工具类:
```swift
static func performSafely(_ block: () throws -> Void) rethrows
static func performWithObjCExceptionHandling(_ block: () -> Void) -> Bool
```
2. 在关键入口包裹 `@try/@catch`
- `RDEPUBTextBookBuilder.build()` 的每章循环体
- `RDEPUBTextLayouter.layoutFrames()` 的分页循环
- `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 的 HTML 处理
**Step 2分页/渲染单章隔离**
3. 单章渲染/分页失败时,降级到空白页或上一次缓存结果,不中断全书构建
4. 在 `RDEPUBTextChapterPaginationDiagnostic` 中记录错误状态
**Step 3全局信号处理可选**
5. 注册 `NSSetUncaughtExceptionHandler` 记录 ObjC 异常
6. 注册 signal handlerSIGABRT, SIGSEGV记录崩溃现场
7. 下次启动时上报(如果后续有上报系统)
**验收标准**
- 单章渲染异常不导致整个 build 中断
- pageCurl 崩溃场景已有保护,验证不退化
- 崩溃日志可追踪到具体章节和阶段
---
### 9. 内存管理
**当前状态**:无显式内存预算。无 `didReceiveMemoryWarning` 处理。`RDEPUBTextBookCache` 磁盘缓存无大小限制。
**实施步骤**
**Step 1内存警告响应**
1. 在 `RDEPUBReaderController` 中监听 `UIApplication.didReceiveMemoryWarningNotification`
2. 收到警告时:
- 清空 `RDReaderPreloadController` 的预加载缓存
- 通知 `RDEPUBTextBookCache` 的内存缓存(如果有)清空
- 释放非当前可见章节的渲染结果
**Step 2章节级内存释放**
3. 在 `RDEPUBTextBookBuilder` 构建完成后,不再持有已构建章节的 `NSAttributedString`
4. 当前只持有 `RDEPUBTextBook`(包含 `[RDEPUBTextPage]` 的 NSRange内存占用已较低
5. 如果后续引入 attributed string 缓存,需增加 LRU 淘汰策略
**Step 3图片内存控制**
6. `RDReaderPreloadController` 预加载的 page view 数量与内存挂钩
7. 当内存压力时减少 `preloadRadius`(从 1 降到 0
**验收标准**
- 在大书79MB构建过程中收到内存警告时app 不被系统杀掉
- 预加载缓存在内存压力下自动收缩
- 当前页内容不丢失
---
### 10. 增量构建
**当前状态**:全书一次性分页。`RDEPUBTextBookBuilder.build()` 遍历全部 spine item输出完整 `RDEPUBTextBook`
**实施步骤**
**Step 1章节级构建接口**
1. 在 `RDEPUBTextBookBuilder` 上新增:
```swift
func buildChapter(
at spineIndex: Int,
publication: RDEPUBPublication,
parser: RDEPUBParser,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> RDEPUBTextChapter?
```
2. 内部逻辑从 `build()` 的循环体中提取,单章可独立构建
**Step 2按需加载**
3. `RDEPUBReaderLoadCoordinator` 在打开书时只构建当前章节和相邻 ±1 章
4. 用户翻到新章节时,后台异步构建该章节
5. 使用 `DispatchQueue` 串行队列保证线程安全
**Step 3与缓存联动**
6. `RDEPUBPaginationCacheCoordinator` 支持单章缓存命中检查
7. 缓存命中时跳过构建,直接加载
**验收标准**
- 大书首次打开时间缩短(只构建 3 章而非全书)
- 翻到未构建章节时无明显卡顿(后台预构建)
- 全书构建 API 保持不变(兼容现有调用方)
---
### 11. 缓存管理
**当前状态**`RDEPUBTextBookCache` 磁盘缓存无大小限制、无淘汰策略、无选择性失效。无内存缓存。
**实施步骤**
**Step 1磁盘缓存容量控制**
1. 新增 `maxDiskCacheSize: UInt64`(默认 200MB
2. 在 `save(_:key:)` 写入后检查总大小
3. 超限时按修改时间删除最旧的缓存文件,直到低于阈值
**Step 2选择性失效**
4. 新增 `invalidate(key:)` 方法,删除指定缓存文件
5. 当用户修改 `fontChoice``fontSize` 时,只失效当前书的缓存(而非 `invalidateAll`
**Step 3内存缓存NSCache**
6. 在 `RDEPUBTextBookCache` 上层增加 `NSCache<NSString, PaginationCacheArchive>`
7. `load(key:)` 先查内存缓存miss 后查磁盘并回填
8. 内存警告时清空 NSCache
**Step 4缓存统计持久化**
9. `RDEPUBTextBookCache` 新增 `cacheStats` 属性,记录总命中/缺失次数
10. 可用于调试和性能分析
**验收标准**
- 磁盘缓存不超过设定上限
- 修改字号/字体后,旧缓存被正确失效
- 连续打开同一本书时,第二次命中内存缓存(< 1ms
---
## 三、可访问性
### 12. VoiceOver
**当前状态**9 个 `accessibilityIdentifier`(仅用于 UI 测试),无 `accessibilityLabel`、`accessibilityHint`、`accessibilityTraits`。阅读内容区域无任何无障碍支持。
**实施步骤**
**Step 1工具栏无障碍最低门槛**
1. `RDEPUBReaderTopToolView`
- `backButton.accessibilityLabel = "返回"`
- `bookmarkButton.accessibilityLabel = "书签"` + `accessibilityValue` 反映当前状态(已添加/未添加)
- `titleLabel.accessibilityLabel` = 书籍标题
2. `RDEPUBReaderBottomToolView`
- 各按钮补充 `accessibilityLabel`"目录"、"书签列表"、"高亮列表"、"添加高亮"、"设置"
- 进度 slider 补充 `accessibilityValue`"第 X 页,共 Y 页"
**Step 2设置面板**
3. `RDEPUBReaderSettingsViewController` 所有控件补充 label 和 traits
**Step 3阅读内容区域中等难度**
4. `RDEPUBTextContentView` 设置 `isAccessibilityElement = true`
5. `accessibilityLabel` = 当前页纯文本内容
6. 翻页时发出 `UIAccessibility.pageScrolledNotification`
**Step 4目录和高亮列表**
7. `RDEPUBReaderChapterListController` 列表项设置 `accessibilityLabel`(章节标题 + 页码)
8. `RDEPUBReaderHighlightsViewController` 列表项设置 `accessibilityLabel`(高亮文本 + 章节)
**验收标准**
- VoiceOver 用户能完整导航工具栏
- 翻页时 VoiceOver 朗读新页内容
- 目录和高亮列表可被 VoiceOver 逐项朗读
---
### 13. Dynamic Type
**当前状态**:字号由 `RDEPUBReaderConfiguration.fontSize` 控制,不响应系统 `UIContentSizeCategory` 变化。
**实施步骤**
1. 在 `RDEPUBReaderSettingsViewController` 中监听 `UIContentSizeCategory.didChangeNotification`
2. 当系统字体大小变化时,根据新的 `UIContentSizeCategory` 计算等效字号
3. 或者:在 `RDEPUBReaderConfiguration` 中增加 `useSystemFontSize: Bool`,启用时忽略 `fontSize`,使用系统推荐值
4. 字号映射表(参考 Apple 的 `preferredFont(forTextStyle:)` 返回值)
**验收标准**
- 系统字体调大后,阅读器字号自动跟随
- 用户手动调节字号后,覆盖系统设置
---
### 14. 高对比度
**当前状态**6 个固定主题预设,不跟随系统 `UIAccessibility.isDarkerSystemColorsEnabled`
**实施步骤**
1. `RDEPUBReaderTheme` 增加 `highContrastVariant: RDEPUBReaderTheme?` 属性
2. 监听 `UIAccessibility.darkerSystemColorsStatusDidChangeNotification`
3. 高对比度启用时,自动切换到高对比度主题变体(更大色彩对比度)
4. 或提供独立的"高对比度"主题预设
**验收标准**
- 系统高对比度开启后,阅读器自动切换到高对比度主题
- 关闭后恢复原主题
---
### 15. DRM
**当前状态**:需求文档明确声明 DRM 不在 SDK 范围内。如果需要支持,建议通过以下方式:
**建议方案**
1. 不在 SDK 内实现 DRM而是通过 `RDEPUBParser` 的输入端控制:
- `RDEPUBParser` 接受 `Data``URL`,调用方可以先解密再传入
- 或新增 `RDEPUBDRMProvider` 协议:
```swift
protocol RDEPUBDRMProvider {
func decryptedData(for resourceURL: URL) -> Data?
func isProtected(_ resourceURL: URL) -> Bool
}
```
2. SDK 内的 `ss-reader://` URL scheme handler 在读取资源时调用 `DRMProvider`
3. 商业 DRM如 Adobe ADEPT、Readium LCP由调用方集成SDK 提供接入点
**验收标准**
- SDK 提供清晰的 DRM 接入协议
- 不引入 DRM 依赖,保持 SDK 轻量
- 调用方能通过协议接入自己的 DRM 方案

162
Doc/阅读器规划.md Normal file
View File

@ -0,0 +1,162 @@
# 阅读器规划:从架构重构到商业版的差距
## 背景
渲染内核的架构对齐WXRead 拆分类重构)解决的是"代码可维护性"问题。
本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力。
每项能力标注了三方状态:当前 ReadViewSDK 实现情况、WXRead微信读书反编译源码中的实现情况。
## 一、渲染质量(最直接影响用户体验)
### 三方对比总览
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
| --- | --- | --- | --- | --- |
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持。WXRead 同样不支持,不是对标项 |
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理。DTCoreText 限制,需 WebView 回退 |
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 我们无任何支持。WXRead 打包 mathFonts.bundle 做数学符号字体回退,但无 MathML 解析 |
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现,见下方详情对比 |
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备UI 未见 | 我们已支持系统/宋体/圆体/等宽四档字体选择、持久化、重新分页和缓存隔离。WXRead 有 15+ 内置字体,后续差距主要是字体包数量 |
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现断字逻辑 |
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | 我们有拉丁/CJK 分轨 CSS。WXRead 额外有简繁转换和更完善的字体回退 |
### 逐项详情
#### 1. 复杂图文分页质量
双方均已实现,核心能力对齐情况:
| 子项 | ReadViewSDK | WXRead |
| --- | --- | --- |
| `avoidPageBreakInside` | ✅ 反向扫描行,`kMaxLinesToRemove=3`,保护 table/code/list/blockquote | ✅ 同算法,`kMaxLinesToRemove=3`,保护同类元素 |
| `pageBreakBefore` / `pageBreakAfter` | ✅ 语义标记注入 + 分页引擎消费 | ✅ U+2028 LINE SEPARATOR + DTPageBreakBefore/AfterAttribute |
| `pageRelate` 跨页关联 | ✅ `weread-page-relate` 语义注入 | ✅ `weread-page-relate:true` CSS 属性 |
| `keepWithNext` | ✅ `trimmedRangeForKeepWithNext` 实现 | ❌ 未实现(我们反而领先) |
| 图片垂直居中 | ✅ `wr-vertical-center-style:2` + bodyPic 包装 | ✅ 同机制 |
| 图片缩放 | ✅ 动态 `imageMaxHeightRatio=0.85` | ✅ 四档 full/half/third/quarter + 1080x1920 上限 |
| 暗色模式图片处理 | ✅ 已实现 | ✅ 图片与主题背景色 85% 混合 |
| 孤行/寡行控制 | ⚠️ 配置标记有,分页逻辑未引用 | ⚠️ `avoidOrphans`/`avoidWidows` 声明,实际机制未完全还原 |
**结论**:图文分页质量双方基本对齐,我们甚至在 `keepWithNext` 上领先。暗色模式图片处理已补齐,后续主要是继续扩充分页回归基准和更多真实书籍样本。
#### 2. 字体选择器
| 子项 | ReadViewSDK | WXRead |
| --- | --- | --- |
| 字号调节 | ✅ A-/A+ 按钮 | ✅ A-/A+ 按钮12-36pt |
| `fontFamily` 持久化 | ✅ `RDEPUBReaderFontChoice` 持久化 | ✅ NSUserDefaults `WRTypesetterFontFamily` |
| CSS / 排版动态生成 | ✅ 渲染样式、分页缓存签名和重新分页均接入当前字体 | ✅ `_WRBuildUserSettingsCSS` 动态生成 `font-family` CSS |
| 内置字体 | ⚠️ 系统/宋体/圆体/等宽四档 | ✅ 15+思源宋体、方正兰亭黑、OpenDyslexic 等) |
| 字体选择面板 UI | ✅ 设置面板分段控件 | ⚠️ 反编译代码中未见,可能在未包含的模块 |
**当前实现状态**:字体选择器已可用,切换字体会触发重新分页,并纳入分页缓存 key避免不同字体复用旧分页。后续如果继续对标 WXRead重点是引入更多内置字体包和字体预览样式。
#### 3. 多语言排版
| 子项 | ReadViewSDK | WXRead |
| --- | --- | --- |
| 拉丁/CJK 语言检测 | ✅ `prefersLatinLanguageCSS` | ✅ `isLatinLanguageBook` |
| 分轨 CSS | ✅ `wxread-replace.css` / `wxread-replace-latin.css` | ✅ `replace.css` / `replaceForLatinLanguageBook.css` |
| 简繁转换 | ❌ 无 | ✅ `CFStringTransform` Hans→Latin→Hant |
| `lang` 属性处理 | ✅ 提取语言代码 | ✅ `DTHTMLElement.lang` |
| `direction` (ltr/rtl) | ✅ `RDEPUBReadingProgression` | ✅ `DTHTMLElement.direction` |
#### 4. Hyphenation 断字
| 子项 | ReadViewSDK | WXRead |
| --- | --- | --- |
| 配置标记 | ✅ `hyphenation: Bool = true` | ✅ `BOOL hyphenation = YES` |
| 实际断字逻辑 | ❌ 未应用 `NSParagraphStyle.hyphenationFactor` | ❌ 未使用,行分割仍用 `CTTypesetterSuggestLineBreak` |
**结论**:双方状态一致,都是声明了但未实现。
#### 5. 未实现且 WXRead 也未实现的项
| 项 | ReadViewSDK | WXRead | 建议 |
| --- | --- | --- | --- |
| **竖排文字** | ❌ | ❌ | 非核心需求,双方均未做 |
| **Ruby 注音** | ❌ | ❌ | DTCoreText 限制,需 WebView 回退方案 |
| **数学公式** | ❌ | ⚠️ 仅字体 | 非原生渲染范畴,需 WebView 回退 |
## 二、功能完整度
| 缺失项 | 严重度 | 说明 |
| --- | --- | --- |
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
## 三、工程成熟度
| 缺失项 | 严重度 | 说明 |
| --- | --- | --- |
| **自动化测试** | 高 | ✅ UI 自动化测试已启动并接入 Demo 流程,覆盖打开书籍、阅读器基础交互、工具栏、设置面板、字体选择、暗色主题切换等。仍缺单元测试和分页回归基准 |
| **性能基线** | 高 | 有 `RDEPUBTextPerformanceSampler` 但无持续监控。大书100MB+)首屏时间、内存峰值无基准 |
| **崩溃防护** | 中 | 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获和上报 |
| **内存管理** | 中 | 无显式内存预算,大书场景下无章节级内存释放策略 |
| **增量构建** | 中 | 全书一次性分页,无章节级增量重建能力 |
| **缓存管理** | 中 | UserDefaults 存储有限,无磁盘缓存大小控制和淘汰策略 |
## 四、可访问性与合规
| 缺失项 | 严重度 | 说明 |
| --- | --- | --- |
| **VoiceOver** | 高 | 有 accessibility identifier 但无阅读内容的 VoiceOver 流程 |
| **Dynamic Type** | 中 | 不响应系统字体大小设置 |
| **高对比度** | 低 | 主题固定,不跟随系统 trait |
| **DRM** | 看业务 | 需求文档已声明不在此范围,但商业分发通常需要 |
## 五、按优先级排序的建议路线
### P0 — 影响商业发布
1. **分页回归基准**Phase 9 后续)— UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
2. **字体选择器增强** — 基础字体选择器已实现。后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
3. **书架/书库管理** — 商业阅读器的入口,没有书架就没有产品形态
4. **暗色模式图片处理增强** — 基础处理已实现。后续可补:按图片亮度自适应混合比例、真实书籍样本回归
### P1 — 影响用户留存
5. **批注导出/分享** — 深度阅读用户的核心需求
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
8. **简繁转换** — WXRead 有 `CFStringTransform` 简繁转换,面向港澳台用户需要
### P2 — 提升竞争力
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
10. **云端同步** — 多设备用户的基本需求
11. **VoiceOver 完善** — 合规和品牌形象
12. **全局搜索** — 藏书量大时的效率工具
### 不需要对标 WXRead 的项
以下项 WXRead 自身也未实现,不属于必须补齐的能力:
| 项 | ReadViewSDK | WXRead | 建议 |
| --- | --- | --- | --- |
| **竖排文字** | ❌ | ❌ | 非核心需求,可延后 |
| **Ruby 注音** | ❌ | ❌ | DTCoreText 限制,需 WebView 回退方案 |
| **数学公式** | ❌ | ⚠️ 仅字体 | 非原生渲染范畴,需 WebView 回退 |
| **Hyphenation 断字** | ⚠️ 仅标记 | ⚠️ 仅标记 | 双方均未实现,中文场景影响小 |
### ReadViewSDK 领先 WXRead 的项
| 项 | 说明 |
| --- | --- |
| **`keepWithNext`** | 我们实现了 `trimmedRangeForKeepWithNext`WXRead 反编译代码中未找到对应实现 |
## 六、总结
经过三方对比,渲染质量层面的真实差距比最初评估要小:
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
- **真正的差距**:字体包数量、分页回归基准、简繁转换
- **WXRead 也没做的**竖排、ruby、公式、hyphenation——这些不是必须对齐的
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。

View File

@ -7,14 +7,41 @@
objects = {
/* Begin PBXBuildFile section */
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */; };
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
053B5CD55E42BCD8E4E77C14 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DE070C152FBF0CC900ED065F /* Project object */;
proxyType = 1;
remoteGlobalIDString = DE070C1C2FBF0CC900ED065F;
remoteInfo = ReadViewDemo;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = "<group>"; };
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.debug.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig"; sourceTree = "<group>"; };
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Launch.swift"; sourceTree = "<group>"; };
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -39,6 +66,14 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3B8BD3932B95613EF7F4A449 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C1A2FBF0CC900ED065F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -50,10 +85,28 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0F03902FB2E1DBD16B6E862F /* iOS */ = {
isa = PBXGroup;
children = (
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
5354236B404004825520D52F /* Helpers */ = {
isa = PBXGroup;
children = (
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
9F8446930C35441C7E9A4DFF /* Frameworks */ = {
isa = PBXGroup;
children = (
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */,
0F03902FB2E1DBD16B6E862F /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
@ -74,6 +127,7 @@
DE070C1E2FBF0CC900ED065F /* Products */,
BAF21F51585EC06F8713C5C6 /* Pods */,
9F8446930C35441C7E9A4DFF /* Frameworks */,
E31A99F5F070435147BC13CC /* ReadViewDemoUITests */,
);
sourceTree = "<group>";
};
@ -81,13 +135,53 @@
isa = PBXGroup;
children = (
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */,
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
E31A99F5F070435147BC13CC /* ReadViewDemoUITests */ = {
isa = PBXGroup;
children = (
5354236B404004825520D52F /* Helpers */,
EACA0D274176D594361EF6B9 /* ReaderUITests */,
);
path = ReadViewDemoUITests;
sourceTree = "<group>";
};
EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
isa = PBXGroup;
children = (
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
);
path = ReaderUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
7BE8E325ADE250D48D1A997A /* ReadViewDemoUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADC5920FDF58B08AA59F7567 /* Build configuration list for PBXNativeTarget "ReadViewDemoUITests" */;
buildPhases = (
3033E27A4E50D8AF46B7388D /* Sources */,
3B8BD3932B95613EF7F4A449 /* Frameworks */,
ADEE76F68D081663B23CD0BB /* Resources */,
);
buildRules = (
);
dependencies = (
B0E4ACDE6B79427A20B5BE9A /* PBXTargetDependency */,
);
name = ReadViewDemoUITests;
productName = ReadViewDemoUITests;
productReference = 8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
DE070C1C2FBF0CC900ED065F /* ReadViewDemo */ = {
isa = PBXNativeTarget;
buildConfigurationList = DE070C302FBF0CCA00ED065F /* Build configuration list for PBXNativeTarget "ReadViewDemo" */;
@ -140,11 +234,19 @@
projectRoot = "";
targets = (
DE070C1C2FBF0CC900ED065F /* ReadViewDemo */,
7BE8E325ADE250D48D1A997A /* ReadViewDemoUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
ADEE76F68D081663B23CD0BB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C1B2FBF0CC900ED065F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -185,10 +287,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
@ -197,6 +303,20 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3033E27A4E50D8AF46B7388D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C192FBF0CC900ED065F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -206,7 +326,63 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B0E4ACDE6B79427A20B5BE9A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = ReadViewDemo;
target = DE070C1C2FBF0CC900ED065F /* ReadViewDemo */;
targetProxy = 053B5CD55E42BCD8E4E77C14 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
258209CBC2293BAD3EF2B836 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TouchLinked_dev;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = ReadViewDemo;
VALIDATE_PRODUCT = YES;
WRAPPER_EXTENSION = xctest;
};
name = Release;
};
2995C92FBA100970A8A427B4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TouchLinked_dev;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = ReadViewDemo;
WRAPPER_EXTENSION = xctest;
};
name = Debug;
};
DE070C312FBF0CCA00ED065F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */;
@ -407,6 +583,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
ADC5920FDF58B08AA59F7567 /* Build configuration list for PBXNativeTarget "ReadViewDemoUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
258209CBC2293BAD3EF2B836 /* Release */,
2995C92FBA100970A8A427B4 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DE070C182FBF0CC900ED065F /* Build configuration list for PBXProject "ReadViewDemo" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@ -661,6 +661,9 @@ final class ViewController: UIViewController {
if let automationPlan {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in
if let displayType = automationPlan.displayType {
controller?.applyDemoDisplayType(displayType)
}
if let pageNumber = automationPlan.pageNumber {
_ = controller?.goToDemoPage(pageNumber)
}
@ -688,7 +691,7 @@ final class ViewController: UIViewController {
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
let configuration = RDEPUBReaderConfiguration.default
let font = UIFont.systemFont(ofSize: configuration.fontSize)
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Some files were not shown because too many files have changed in this diff Show More