5 Commits
Author SHA1 Message Date
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00
shenlei e01cbc169d 修复 EPUB 与 PDF 阅读器朗读交互
PDF 朗读的逐词回调会在同一页内持续触发高亮,原实现每次都调用翻页入口,使用户在朗读过程中反复看到页面转场。现仅在目标页变化时定位,并对朗读产生的跨页定位禁用动画,保留高亮刷新而不重复翻页。\n\nEPUB/TXT 此前只提供内容源,缺少将朗读进度回写到阅读器的会话层,无法从当前阅读位置开始朗读、随内容跨页及显示临时高亮。新增 EPUB 朗读会话,使用 href 与 UTF-16 偏移恢复位置;文本重排 EPUB 与 TXT 按实际页面定位并高亮,Web EPUB 则按章节安全定位以避免词级回调导致跳页。URL 阅读器同时提供可选会话入口,图片型阅读器保持不可朗读。\n\n已执行 ReadViewDemo 的 Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 18:33:04 +09:00
shenleiandClaude Opus 5 529663829f 修复退出 EPUB 阅读器后宿主侧滑返回手势被改成阅读器的配置
阅读器在 viewWillAppear 中无条件把导航控制器的 interactivePopGestureRecognizer
接管为自己的 delegate 并强制开启,退出时却不做任何还原。宿主如果原本关闭了
侧滑返回,或为它设置过自定义 delegate,从阅读器返回后这些配置就被永久改写,
其它页面的返回手势行为随之变化。阅读器自身接管横向翻页,进入期间开启侧滑返回
也会与翻页手势争抢边缘触摸。

改为进入时记录原有的开关状态与 delegate 并直接关闭该手势,退出时按记录值还原。
记录只在首次 viewWillAppear 时进行,避免阅读器内部多次前后台切换把自己写入的
状态当成宿主原值。

该修复原本以本地补丁形式存在于 readoor 项目的 Vendor 副本中,现回流上游,避免
后续同步 SDK 时反复丢失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:03:43 +09:00
shenleiandClaude Opus 5 d830311894 修复 PDF 仿真翻页模式下翻页后页面全白
打开普通 PDF 并使用仿真翻页阅读时,除首页外每翻一页都是纯白:没有页面
内容,也没有页面四周的衬底色。横向滚动与竖滑模式不受影响。样张
英汉大词典App需求说明.pdf 上第 2 到第 9 页无一例外。

根因是页面数据回来时找不到对应的视图。UIPageViewController 会通过
viewControllerBefore/After 提前构建相邻页的内容视图,构建时页面描述尚未
就绪,configure 发起异步 readerPage 请求后先返回一张空白页。描述返回后
acceptPageDescriptor 调用 refreshVisiblePage,而它经由
RDPDFReaderView.pageContentView(pageNum:) 定位视图,该方法在仿真翻页下
只检索 nativeCurlPageController.viewControllers,也就是已经上屏的 child。
预建的 child 此时还不在其中,图片因此从未被装配上去;翻页动画结束后又
没有任何路径重新 configure 该视图,白页就固定下来。此前偶尔能看到内容,
是 OCR 完成回调顺带触发了一次刷新,并非稳定行为。

改为按页码刷新该页的全部活动视图:控制器用弱引用表记录已创建的页面视图,
refreshVisiblePage 改名为 refreshPageViews(at:),除已上屏视图外一并装配
尚未上屏的预建视图,pageContentView 创建时即写入 tag 以便按页码匹配。
翻页落定时在 pageNum 回调中补一次装配,使内存告警清掉页面描述的场景也能
重新发起请求并恢复。另外把 image 为空的页面描述改按无效结果处理,走既有
的重试与失败重载入口,不再缓存出一张永远不会重试的白页。

新增 PDFPageTurnBlankPageTests 作为回归:翻页稳定后截屏统计画面中部深色
像素占比,白页为 0,扉页约 0.002,正文页在 0.01 以上,以 0.0005 为判定
阈值,覆盖仿真翻页正翻、仿真回翻和横向滚动三种路径。

验证:iPhone 17 Pro / iOS 26.4.1 模拟器执行 PDFPageTurnBlankPageTests、
PDFAnnotationTests、PDFDrawingModeTests、PageNavigationTests、
DisplayTypeTests 共 23 条用例,22 条通过。修复前仿真翻页第 2 到 9 页墨迹
占比全为 0.0000,修复后与横向滚动模式逐页吻合。唯一失败的
DisplayTypeTests.testDisplayTypeSwitchMidSession 属 EPUB 设置面板既有问题,
将本次改动还原后同样失败。文档补充了 Demo 因链接 FoundationModels 而必须
使用 iOS 26 及以上模拟器的说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:52:29 +09:00
shenleiandClaude Opus 5 735c14c824 隐藏 PDF Demo 阅读器中的 AI 助手入口与 TTS 朗读控件
PDF Demo 宿主在阅读界面右上角常驻一个 "AI" 按钮,底部常驻一组 TTS 播放
控件。这两处是示例性质的能力入口,当前不希望在阅读界面上出现。

在宿主控制器上引入 showsAIAssistant 与 showsSpeechControls 两个开关,
viewDidLoad 中据此决定是否创建 AI 按钮、是否装配朗读控件,两者都不再被
加入视图层级。底层的 presentAIAssistant、speechSession 及播放、语速控制
逻辑全部保留,把开关置为 true 即可恢复,避免以后重新接入时再写一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:51:57 +09:00
49 changed files with 3581 additions and 2726 deletions
+7 -3
View File
@@ -251,13 +251,17 @@ public protocol RDEPUBReaderPersistence: AnyObject {
} }
``` ```
默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储: 默认实现 `RDEPUBUserDefaultsPersistence` 将阅读位置和全局设置存入 UserDefaults;
书签与高亮(可能含书籍正文摘录)存入带文件保护且排除备份的 Application Support 文件。
升级后的首次读取会自动迁移历史 UserDefaults 数据,并仅在新副本成功落盘后删除旧副本。
不同的 `UserDefaults` 容器会自动使用独立的文件命名空间,避免多账号或测试 suite
之间混用同一本书的标注;标准容器保留对旧版未分区文件的只读兼容。
| 数据 | Key 格式 | | 数据 | Key 格式 |
|------|----------| |------|----------|
| 阅读位置 | `ssreader.epub.location.{bookID}` | | 阅读位置 | `ssreader.epub.location.{bookID}` |
| 书签 | `ssreader.epub.bookmarks.{bookID}` | | 书签 | 受保护文件(历史键:`ssreader.epub.bookmarks.{bookID}`) |
| 高亮 | `ssreader.epub.highlights.{bookID}` | | 高亮 | 受保护文件(历史键:`ssreader.epub.highlights.{bookID}`) |
| 全局设置 | `ssreader.epub.settings` | | 全局设置 | `ssreader.epub.settings` |
--- ---
+12
View File
@@ -1,6 +1,18 @@
# 代码库风险与关注点 # 代码库风险与关注点
**分析日期:** 2026-05-21 **分析日期:** 2026-05-21
**状态复核:** 2026-07-30(逐条对照源码,结论见下表;正文段落保留原始分析,勿当作现状)
| # | 项目 | 现状 |
|---|------|------|
| 1 | User Script Sandboxing 被关 | **部分处理**:已移除 Podfile 的全局强制覆盖;但显式打成 `YES` 会让 CocoaPods 的 `[CP] Embed Pods Frameworks` rsync 阶段被沙箱拒绝而构建失败(已实测)。要真正开启需先改掉 `use_frameworks!` 动态框架集成方式。 |
| 2 | 外链未做 allowlist | **已修**:`RDEPUBReaderController+ExternalLinks.swift` 有 scheme allowlist + 二次确认弹窗 + delegate 否决。 |
| 3 | Zip Slip 未加固 | **已修**:`RDEPUBParser+Archive.swift` 的 `validatedExtractionDestination` 做 standardized 前缀校验并拒绝 `..`/绝对路径。 |
| 4 | 高亮/选中文本明文写 UserDefaults | **已修**:新增 `RDEPUBProtectedContentStore`,高亮与书签改写 Application Support 下带 file protection 的文件、排除备份,首次读取自动迁移并删除明文;另加 `clearReadingData(for:)` / `clearAllReadingContent()`。 |
| 5 | Scheme handler 整文件读内存 | **原判断有误**:>512KB 早已走 `respondWithStreaming` 64KB 分块。本次补上 `maximumInMemoryResourceBytes`(默认 64MB)给走不了流式的加密 provider 分支兜底。 |
| 6 | 分页 WKWebView 可能加载外部资源 | 未复核。 |
| 7 | 解压缓存无限累积 | **已修**:`pruneExtractionCache(keeping:)` 先按 `extractionCacheMaximumAge`(30 天)再按 `extractionCacheMaximumTotalBytes`(512MB)做 LRU 淘汰;复用时 touch mtime;另有 `clearExtractionCache()`。 |
| 8+ | 其余性能/可维护性项 | 未复核。 |
## 高风险:安全与隐私 ## 高风险:安全与隐私
+7
View File
@@ -34,6 +34,7 @@
| LocationPersistenceTests.swift | 阅读位置持久化 | | LocationPersistenceTests.swift | 阅读位置持久化 |
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 | | MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
| PageNavigationTests.swift | 页面导航 | | PageNavigationTests.swift | 页面导航 |
| PDFPageTurnBlankPageTests.swift | PDF 翻页后白页回归(截屏墨迹占比判定) |
| ReaderAnnotationTests.swift | 标注基础功能 | | ReaderAnnotationTests.swift | 标注基础功能 |
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 | | ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 | | ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
@@ -72,6 +73,12 @@ cd ReadViewDemo && pod install
(依赖入口:`ReadViewDemo/Podfile`) (依赖入口:`ReadViewDemo/Podfile`)
### 模拟器要求
Demo 链接了 `FoundationModels.framework`(AI 助手示例入口)。该框架自 iOS 26 起提供,
在 iOS 18 及更早的模拟器上 App 会在启动时因 `Library not loaded` 直接崩溃,测试表现为
卡在 Launch 阶段。请选择 iOS 26 及以上的模拟器运行 UI 测试。
### 运行 UI 测试 ### 运行 UI 测试
```bash ```bash
+1 -1
View File
@@ -25,7 +25,7 @@
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险 | | [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险(顶部有 2026-07-30 状态复核表) |
| [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 | | [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 |
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 | | [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
+8 -1
View File
@@ -17,8 +17,15 @@ target 'ReadViewDemo' do
end end
post_install do |installer| post_install do |installer|
# 不再由本 Podfile 全局关闭 ENABLE_USER_SCRIPT_SANDBOXING——之前的写法会把
# 宿主工程一起强制改成 NO,属于我们主动降级安全基线。现在交回工程默认值。
#
# 注意:本工程用 use_frameworks!,CocoaPods 生成的 [CP] Embed Pods Frameworks
# 脚本阶段用 rsync 往 .app/Frameworks 拷贝产物;一旦把该设置显式打成 YES,
# 这一步会被沙箱拒绝(file-write-unlink / utimensat: Operation not permitted)
# 而整个构建失败——已实测确认。真要打开必须先改掉动态框架集成方式
# (静态库 / SPM),别只翻这个开关。
apply_settings = lambda do |config| apply_settings = lambda do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end end
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9 RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
@@ -43,7 +43,7 @@
"~> 0.1" "~> 0.1"
] ]
}, },
"frameworks": "FoundationModels" "weak_frameworks": "FoundationModels"
}, },
{ {
"name": "UI", "name": "UI",
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9 RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
@@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
@@ -2,7 +2,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "NaturalLanguage" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -2,7 +2,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "NaturalLanguage" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -42,6 +42,7 @@
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; }; 9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; }; A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; };
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -95,6 +96,7 @@
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; }; 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; }; A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFPageTurnBlankPageTests.swift; sourceTree = "<group>"; };
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; }; CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
@@ -213,6 +215,7 @@
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */, FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */, 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */, A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */,
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */,
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */, 2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */,
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */, 2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */,
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */, 2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */,
@@ -394,6 +397,7 @@
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */, 1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */, A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */,
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */,
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */, 2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */,
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */, 2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */,
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */, 2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */,
@@ -452,7 +456,6 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -477,7 +480,6 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -504,7 +506,6 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist; INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -543,7 +544,6 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist; INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -608,7 +608,6 @@
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -671,7 +670,6 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -152,6 +152,9 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
/// 成品阅读器的 Demo 宿主:存储位置、返回导航和自动化状态是唯一的应用职责。 /// 成品阅读器的 Demo 宿主:存储位置、返回导航和自动化状态是唯一的应用职责。
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate { final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations" private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
/// 暂时隐藏阅读器中的 AI 助手入口与 TTS 朗读控件,实现保留以便随时恢复。
private static let showsAIAssistant = false
private static let showsSpeechControls = false
private let reader: RDPDFReaderViewController private let reader: RDPDFReaderViewController
private lazy var speechSession: RDPDFSpeechSession = { private lazy var speechSession: RDPDFSpeechSession = {
let session = reader.makeSpeechSession() let session = reader.makeSpeechSession()
@@ -224,7 +227,8 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(reader.view) view.addSubview(reader.view)
reader.didMove(toParent: self) reader.didMove(toParent: self)
configureSpeechControls() if Self.showsSpeechControls { configureSpeechControls() }
if Self.showsAIAssistant {
let assistantButton = UIButton(type: .system) let assistantButton = UIButton(type: .system)
assistantButton.setTitle("AI", for: .normal) assistantButton.setTitle("AI", for: .normal)
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
@@ -233,6 +237,7 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
view.addSubview(assistantButton) view.addSubview(assistantButton)
assistantButton.translatesAutoresizingMaskIntoConstraints = false assistantButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)]) NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)])
}
stateLabel.translatesAutoresizingMaskIntoConstraints = false stateLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stateLabel) view.addSubview(stateLabel)
NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)]) NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)])
@@ -353,6 +353,9 @@ final class ViewController: UIViewController {
defaults.removeObject(forKey: key) defaults.removeObject(forKey: key)
} }
} }
// 高亮/书签已改为写受保护文件(不再明文进 UserDefaults),
// 只清 defaults 会让标注跨用例残留,必须一起清掉。
RDEPUBProtectedContentStore().removeAll()
PDFDemoReaderViewController.resetPersistedAnnotations() PDFDemoReaderViewController.resetPersistedAnnotations()
} }
@@ -74,10 +74,13 @@ enum IDs {
static let searchClose = "epub.reader.search.close" static let searchClose = "epub.reader.search.close"
static let searchCount = "epub.reader.search.count" static let searchCount = "epub.reader.search.count"
/// EPUB 设置面板是固定高度的 pill 面板,没有 scrollView;这是它的根视图。
static let settingsPanel = "epub.reader.settings.panel"
/// 只有 PDF 设置面板才有 scrollView(`RDPDFReaderSettingsPanelViewController`)。
static let settingsScroll = "epub.reader.settings.scroll" static let settingsScroll = "epub.reader.settings.scroll"
static let settingsBrightness = "epub.reader.settings.brightness" static let settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase" /// 字号是滑块;SDK 从未提供 font.increase / font.decrease 按钮
static let settingsFontDecrease = "epub.reader.settings.font.decrease" static let settingsFontSize = "epub.reader.settings.font.size"
static let settingsFontValue = "epub.reader.settings.font.value" static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice" static let settingsFontChoice = "epub.reader.settings.font.choice"
static let settingsLineHeight = "epub.reader.settings.lineHeight" static let settingsLineHeight = "epub.reader.settings.lineHeight"
@@ -169,4 +169,74 @@ extension XCUIApplication {
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool { private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
requiredButtonIDs.allSatisfy { buttons[$0].exists } requiredButtonIDs.allSatisfy { buttons[$0].exists }
} }
// MARK: - 设置面板
/// 等 EPUB 设置面板出现。面板已改成固定高度布局,所有控件一屏可见,
/// 既没有 scrollView 也不需要滚动才能触达。
@discardableResult
func waitForSettingsPanel(timeout: TimeInterval = 5) -> Bool {
if otherElements[IDs.settingsPanel].waitForExistence(timeout: timeout) {
return true
}
// 面板根视图偶尔不作为 otherElement 暴露,退回用「完成」按钮判定
return buttons[IDs.settingsDone].waitForExistence(timeout: 1)
}
/// 设置面板的字体/行距/翻页方式都是 pill 按钮 + action sheet,不是
/// segmentedControl:点开 pill 后再点选项标题。
@discardableResult
func selectSettingsPillOption(
pillIdentifier: String,
optionTitle: String,
timeout: TimeInterval = 5
) -> Bool {
let pill = buttons[pillIdentifier]
guard pill.waitForExistence(timeout: timeout) else { return false }
pill.tap()
// action sheet 的选项在 iOS 上是 button,取消项同名风险由标题唯一性保证
let option = sheets.buttons[optionTitle].exists
? sheets.buttons[optionTitle]
: buttons[optionTitle]
guard option.waitForExistence(timeout: timeout) else {
// 收起 sheet,避免污染后续断言
if sheets.buttons["取消"].exists {
sheets.buttons["取消"].tap()
}
return false
}
option.tap()
return true
}
/// 字号是 slider(`epub.reader.settings.font.size`),没有增大/减小按钮。
/// 把滑块推到指定归一化位置以触发重排;返回是否成功。
@discardableResult
func adjustSettingsFontSize(toNormalizedPosition position: CGFloat, timeout: TimeInterval = 5) -> Bool {
let slider = sliders[IDs.settingsFontSize]
guard slider.waitForExistence(timeout: timeout) else { return false }
slider.adjust(toNormalizedSliderPosition: position)
return true
}
/// 在当前值基础上增减字号,替代早已不存在的 font.increase / font.decrease 按钮。
@discardableResult
func nudgeSettingsFontSize(by delta: CGFloat, timeout: TimeInterval = 5) -> Bool {
let slider = sliders[IDs.settingsFontSize]
guard slider.waitForExistence(timeout: timeout) else { return false }
let target = min(max(slider.normalizedSliderPosition + delta, 0), 1)
slider.adjust(toNormalizedSliderPosition: target)
return true
}
@discardableResult
func increaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
nudgeSettingsFontSize(by: 0.25, timeout: timeout)
}
@discardableResult
func decreaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
nudgeSettingsFontSize(by: -0.25, timeout: timeout)
}
} }
@@ -465,12 +465,8 @@ final class AsyncChapterLoadingTests: XCTestCase {
// Change font size via settings // Change font size via settings
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮不存在") RunLoop.current.run(until: Date().addingTimeInterval(0.6))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
// Close settings // Close settings
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -41,9 +41,10 @@ final class DisplayTypeTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType] XCTAssertTrue(
XCTAssertTrue(displayTypeControl.waitForExistence(timeout: 5)) app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "竖滑"),
displayTypeControl.buttons.element(boundBy: 2).tap() "翻页方式应可切换到竖滑"
)
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" } app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" }
@@ -45,23 +45,20 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened } app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testSettingsPanelScrollReachesAllControls() throws { func testSettingsPanelReachesAllControls() throws {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let settingsScroll = app.scrollViews[IDs.settingsScroll] XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
settingsScroll.swipeUp() XCTAssertTrue(app.buttons[IDs.settingsDisplayType].waitForExistence(timeout: 5))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
settingsScroll.swipeUp()
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -8,7 +8,9 @@ final class LocationPersistenceTests: XCTestCase {
} }
func testReadingPositionRestoredOnReopen() throws { func testReadingPositionRestoredOnReopen() throws {
app.launchAndOpenSampleBook() // 必须用滚动模式:仿真翻页(默认)下没有 `epub.reader.paging`
// collectionView,swipeLeft 落不到任何东西,页码永远停在 1
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil } _ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
@@ -30,7 +32,8 @@ final class LocationPersistenceTests: XCTestCase {
let backButton = app.buttons[IDs.readerBack] let backButton = app.buttons[IDs.readerBack]
if backButton.waitForExistence(timeout: 3) { backButton.tap() } if backButton.waitForExistence(timeout: 3) { backButton.tap() }
app.launchAndOpenSampleBook() // 重开必须保留状态,否则 --demo-reset-state 会清掉刚存的阅读位置
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: false)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
@@ -61,7 +64,7 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() } if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
if app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5) { app.buttons[IDs.settingsFontIncrease].tap() } app.increaseSettingsFontSize()
if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() } if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() }
let backButton = app.buttons[IDs.readerBack] let backButton = app.buttons[IDs.readerBack]
@@ -74,7 +77,8 @@ final class LocationPersistenceTests: XCTestCase {
} }
func testPositionSurvivesFontChoiceAndLineHeightChange() throws { func testPositionSurvivesFontChoiceAndLineHeightChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true) // 同上:需要真正能翻页的模式
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
@@ -97,17 +101,14 @@ final class LocationPersistenceTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
if fontChoiceControl.buttons.count > 1 { "字体选择控件应可切换"
fontChoiceControl.buttons.element(boundBy: 1).tap() )
} XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准"),
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight] "行距控件应可切换"
XCTAssertTrue(lineHeightControl.waitForExistence(timeout: 5), "行距控件应出现") )
if lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: min(1, lineHeightControl.buttons.count - 1)).tap()
}
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3))
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -131,8 +132,9 @@ final class LocationPersistenceTests: XCTestCase {
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 } app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
app.hideReaderChromeIfNeeded() app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch // 与其它选区用例一致:渲染路径没暴露选区元素时跳过,而不是硬失败在
XCTAssertTrue(content.waitForExistence(timeout: 5)) // 与本用例(跨重开保持)无关的前置步骤上
let content = try app.requireSelectionText()
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5)) let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
@@ -174,11 +176,10 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
if fontChoiceControl.buttons.count > 1 { "字体选择控件应可切换"
fontChoiceControl.buttons.element(boundBy: 1).tap() )
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
let content = app.otherElements[IDs.readerContent].firstMatch let content = app.otherElements[IDs.readerContent].firstMatch
@@ -92,10 +92,8 @@ final class MemoryFootprintTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在") XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在") XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
app.buttons[IDs.settingsFontIncrease].tap()
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在") XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -0,0 +1,147 @@
import XCTest
import UIKit
/// PDF 翻页后白页回归。
///
/// 判定方式:每次翻页稳定后截屏,统计画面中部的深色像素占比。正常 PDF 正文页
/// 含文字与线条,占比明显高于阈值;页面图片没有装配到视图上时几乎全白。
final class PDFPageTurnBlankPageTests: XCTestCase {
private let app = XCUIApplication()
/// 深色像素占比低于该值即视为白页。样张实测:白页为 0.0000,
/// 内容最少的扉页约 0.0020,正文页在 0.0100 以上。
private let blankInkRatioThreshold = 0.0005
override func setUpWithError() throws {
continueAfterFailure = false
}
func testPageCurlForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 8, mode: "仿真翻页")
}
func testHorizontalScrollForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "horizontalscroll")
assertForwardTurnsRenderContent(turns: 8, mode: "横向滚动")
}
/// 回翻同样依赖 `viewControllerBefore` 预建的页面视图,需要单独覆盖。
func testPageCurlBackwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 5, mode: "仿真翻页")
assertBackwardTurnsRenderContent(from: 6, turns: 5, mode: "仿真回翻")
}
// MARK: - 步骤
private func openDictionaryPDF(displayType: String) {
app.launchAndOpenSampleBook(
bookTitleQuery: "英汉大词典",
displayType: displayType,
resetsReaderState: true
)
app.waitForReader(timeout: 30)
app.waitForReaderPage(1, timeout: 30)
// 首页首次渲染需要等待异步解码完成,避免把加载中误判成白页。
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
}
private func assertForwardTurnsRenderContent(turns: Int, mode: String) {
var blankPages: [Int] = []
recordInk(page: 1, mode: mode, blankPages: &blankPages)
for step in 1...turns {
let expectedPage = step + 1
turnToNextPage()
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
// 翻页动画结束后再取样,排除卷页过程中的中间帧。
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func assertBackwardTurnsRenderContent(from startPage: Int, turns: Int, mode: String) {
var blankPages: [Int] = []
for step in 1...turns {
let expectedPage = startPage - step
guard expectedPage >= 1 else { break }
turnPage(forward: false)
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func turnToNextPage() { turnPage(forward: true) }
private func turnPage(forward: Bool) {
let content = app.otherElements[IDs.readerContent].firstMatch
let target: XCUIElement = content.exists ? content : app.windows.firstMatch
let leading = CGVector(dx: 0.08, dy: 0.5)
let trailing = CGVector(dx: 0.88, dy: 0.5)
let start = target.coordinate(withNormalizedOffset: forward ? trailing : leading)
let end = target.coordinate(withNormalizedOffset: forward ? leading : trailing)
start.press(forDuration: 0.05, thenDragTo: end)
}
private func recordInk(page: Int, mode: String, blankPages: inout [Int]) {
let screenshot = XCUIScreen.main.screenshot()
let ratio = inkRatio(of: screenshot.image)
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "\(mode)-page-\(page)-ink-\(String(format: "%.4f", ratio))"
attachment.lifetime = .keepAlways
add(attachment)
if ratio < blankInkRatioThreshold {
blankPages.append(page)
}
}
/// 画面中部深色像素占比。裁掉上下边缘以排除状态栏、工具栏和页面外底色。
private func inkRatio(of image: UIImage) -> Double {
guard let cgImage = image.cgImage else { return 0 }
let cropRect = CGRect(
x: CGFloat(cgImage.width) * 0.12,
y: CGFloat(cgImage.height) * 0.16,
width: CGFloat(cgImage.width) * 0.76,
height: CGFloat(cgImage.height) * 0.68
).integral
guard let cropped = cgImage.cropping(to: cropRect) else { return 0 }
let side = 400
var pixels = [UInt8](repeating: 255, count: side * side)
guard let context = CGContext(
data: &pixels,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: side,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return 0 }
context.draw(cropped, in: CGRect(x: 0, y: 0, width: side, height: side))
let darkCount = pixels.reduce(into: 0) { partial, value in
if value < 150 { partial += 1 }
}
return Double(darkCount) / Double(side * side)
}
}
@@ -301,10 +301,7 @@ final class SearchTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3)) XCTAssertTrue(settingsButton.waitForExistence(timeout: 3))
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -367,10 +364,7 @@ final class SearchTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在") XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -114,10 +114,7 @@ final class SelectionAnnotateTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在") XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -16,9 +16,8 @@ final class SettingsEffectTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在") XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在") XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块不存在")
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在") XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -32,8 +31,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
app.scrollViews[IDs.settingsScroll].swipeUp()
let darkThemeButton = app.buttons[IDs.settingsTheme(5)] let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() } if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
@@ -50,12 +48,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight] app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准")
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened } app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
@@ -68,12 +63,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType] app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "仿真")
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
displayTypeControl.buttons.element(boundBy: 0).tap()
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil } app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
@@ -98,19 +90,21 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5)) XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 5))
let fontValueLabel = app.staticTexts[IDs.settingsFontValue] let fontValueLabel = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3)) XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
let originalFontValue = fontValueLabel.label let originalFontValue = fontValueLabel.label
app.buttons[IDs.settingsFontIncrease].tap() XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
let newFontValue = fontValueLabel.label let newFontValue = fontValueLabel.label
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大后字号 label 应变化") XCTAssertNotEqual(newFontValue, originalFontValue, "增大字号后 label 应变化")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.buttons[IDs.readerBack].tap() app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook() // 必须保留状态:默认的 resetsReaderState 会清掉刚存下的设置,
// 那样这个用例永远测不到持久化
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@@ -158,11 +152,12 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] let fontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5)) XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5))
if fontChoiceControl.buttons.count > 1 { XCTAssertTrue(
fontChoiceControl.buttons.element(boundBy: 1).tap() app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
} "字体应可切换到宋体"
)
XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新") XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -174,7 +169,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let reopenedFontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] let reopenedFontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5)) XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5))
XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持") XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -14,7 +14,7 @@ final class SettingsExtendedTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
} }
func testBrightnessSliderExists() { func testBrightnessSliderExists() {
@@ -25,12 +25,13 @@ final class SettingsExtendedTests: XCTestCase {
func testLineHeightControl() { func testLineHeightControl() {
openSettings() openSettings()
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
lineHeight.buttons["紧凑"].tap() for title in ["紧凑", "标准", "宽松"] {
lineHeight.buttons["标准"].tap() XCTAssertTrue(
lineHeight.buttons["宽松"].tap() app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: title),
"行距应可切换到\(title)"
)
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
@@ -38,10 +39,11 @@ final class SettingsExtendedTests: XCTestCase {
func testDisplayTypeFromSettings() { func testDisplayTypeFromSettings() {
openSettings() openSettings()
let displayType = app.segmentedControls[IDs.settingsDisplayType] XCTAssertTrue(
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "横滑"),
"翻页方式应可切换到横滑"
)
displayType.buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" } app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
} }
@@ -54,13 +56,13 @@ final class SettingsExtendedTests: XCTestCase {
} }
} }
func testScrollSettingsToReachAllControls() { /// 面板是固定高度布局,所有控件一屏内可达,不需要滚动
func testAllSettingsControlsReachableWithoutScrolling() {
openSettings() openSettings()
let scroll = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(scroll.waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsLineHeight].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsDisplayType].waitForExistence(timeout: 3))
} }
} }
@@ -15,18 +15,19 @@ final class SettingsPanelTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let fontValue = app.staticTexts[IDs.settingsFontValue] let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3)) XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap() XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
XCTAssertNotEqual(before, fontValue.label) XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap() XCTAssertTrue(app.decreaseSettingsFontSize(), "字号滑块应存在")
let fontChoice = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoice.waitForExistence(timeout: 3)) app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
fontChoice.buttons["宋体"].tap() "字体应可切换到宋体"
)
let darkTheme = app.buttons[IDs.settingsTheme(5)] let darkTheme = app.buttons[IDs.settingsTheme(5)]
XCTAssertTrue(darkTheme.waitForExistence(timeout: 3)) XCTAssertTrue(darkTheme.waitForExistence(timeout: 3))
@@ -26,7 +26,10 @@ Pod::Spec.new do |s|
s.subspec "FoundationModels" do |foundation_models| s.subspec "FoundationModels" do |foundation_models|
foundation_models.source_files = "FoundationModels/**/*.swift" foundation_models.source_files = "FoundationModels/**/*.swift"
foundation_models.dependency "RDAIReaderView/Core", "~> 0.1" foundation_models.dependency "RDAIReaderView/Core", "~> 0.1"
foundation_models.frameworks = "FoundationModels" # 弱链接:FoundationModels 仅 iOS 26+ 提供,硬链接会让宿主 App 在
# iOS 18 上启动即 dyld 失败。文件内所有类型都是 @available(iOS 26.0, *),
# 低版本不会触达这些符号。
foundation_models.weak_frameworks = "FoundationModels"
end end
s.subspec "UI" do |ui| s.subspec "UI" do |ui|
@@ -25,12 +25,22 @@ extension RDEPUBParser {
} }
func extractArchiveIfNeeded(epubURL: URL) throws -> URL { func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
try Self.extractionQueue.sync {
try extractArchiveIfNeededLocked(epubURL: epubURL)
}
}
/// 同一缓存目录在解压完成前不能被其他 parser 复用。串行化解压阶段后,复用
/// 分支看到的 `container.xml` 才能代表完整书籍,而不是另一个 parser 的半成品。
private func extractArchiveIfNeededLocked(epubURL: URL) throws -> URL {
let fileManager = FileManager.default let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL) let extractionURL = temporaryExtractionDirectory(for: epubURL)
if fileManager.fileExists(atPath: extractionURL.path) { if fileManager.fileExists(atPath: extractionURL.path) {
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
if fileManager.fileExists(atPath: containerURL.path) { if fileManager.fileExists(atPath: containerURL.path) {
// 复用即视为一次访问,刷新时间戳,淘汰时才是真正的 LRU 而非 FIFO
Self.touchExtractionDirectory(extractionURL)
return extractionURL return extractionURL
} }
try? fileManager.removeItem(at: extractionURL) try? fileManager.removeItem(at: extractionURL)
@@ -67,6 +77,8 @@ extension RDEPUBParser {
throw error throw error
} }
Self.touchExtractionDirectory(extractionURL)
Self.pruneExtractionCache(keeping: extractionURL)
return extractionURL return extractionURL
} }
@@ -107,9 +119,7 @@ extension RDEPUBParser {
} }
func temporaryExtractionDirectory(for epubURL: URL) -> URL { func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? let baseURL = Self.extractionCacheRootURL
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path) let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0" let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
@@ -133,6 +143,97 @@ extension RDEPUBParser {
} }
} }
// MARK: - 解压缓存淘汰
extension RDEPUBParser {
private static let extractionQueue = DispatchQueue(label: "com.ssreaderview.epub.extraction-cache")
/// 解压缓存总量上限;每次成功解压后超出即按最近使用时间淘汰最旧的书。
public static var extractionCacheMaximumTotalBytes: UInt64 = 512 * 1024 * 1024
/// 解压目录的最长保留时长,超过即淘汰(默认 30 天)。
public static var extractionCacheMaximumAge: TimeInterval = 30 * 24 * 60 * 60
public static var extractionCacheRootURL: URL {
let fileManager = FileManager.default
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? fileManager.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
}
/// 清空全部解压缓存。宿主收到低存储信号或需要「清理阅读数据」时调用。
public static func clearExtractionCache() {
try? FileManager.default.removeItem(at: extractionCacheRootURL)
}
/// 按「先过期、再总量」两级策略淘汰解压目录。
/// - Parameter keeping: 本次正在使用的目录,任何情况下都不淘汰。
public static func pruneExtractionCache(keeping survivor: URL? = nil) {
let fileManager = FileManager.default
let root = extractionCacheRootURL
guard let children = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let survivorPath = survivor?.standardizedFileURL.path
var entries: [(url: URL, size: UInt64, modifiedAt: Date)] = []
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey])
guard values?.isDirectory == true else { continue }
let modifiedAt = values?.contentModificationDate ?? .distantPast
if child.standardizedFileURL.path == survivorPath {
continue
}
if Date().timeIntervalSince(modifiedAt) > extractionCacheMaximumAge {
try? fileManager.removeItem(at: child)
continue
}
entries.append((child, directorySize(at: child), modifiedAt))
}
// survivor 也占额度,否则刚解压的大书会把其他书全部挤掉却仍然超限
var total = entries.reduce(UInt64(0)) { $0 + $1.size }
if let survivor {
total += directorySize(at: survivor)
}
guard total > extractionCacheMaximumTotalBytes else { return }
for entry in entries.sorted(by: { $0.modifiedAt < $1.modifiedAt }) {
try? fileManager.removeItem(at: entry.url)
total = total > entry.size ? total - entry.size : 0
if total <= extractionCacheMaximumTotalBytes { return }
}
}
fileprivate static func touchExtractionDirectory(_ url: URL) {
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
}
private static func directorySize(at url: URL) -> UInt64 {
guard let enumerator = FileManager.default.enumerator(
at: url,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
options: []
) else {
return 0
}
var total: UInt64 = 0
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
total += UInt64(size)
}
return total
}
}
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
private(set) var rootFilePath: String? private(set) var rootFilePath: String?
@@ -12,6 +12,11 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
public static let host = "book" public static let host = "book"
/// 单个资源走「整体读入内存」分支时的上限。超过则优雅失败而不是冒着
/// 内存峰值被系统终止的风险。只影响无法流式的分支(配置了加密资源
/// provider 时密文必须整体解密),流式分支不受此限制。
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
private weak var parser: RDEPUBParser? private weak var parser: RDEPUBParser?
private let fileManager = FileManager.default private let fileManager = FileManager.default
@@ -87,9 +92,18 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved") RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
let fileSize = resourceSize(for: fileURL) let fileSize = resourceSize(for: fileURL)
// 配置了加密资源 provider 时不能走流式分支:密文必须整体解密后再响应, // Provider 可以只解密部分资源并对明文资源返回 nil。先在 IO 队列确认是否
// 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读) // 真的拿到解密数据,再决定是否需要受内存上限约束,不能只因 provider 存在
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil { // 就拒绝大型明文音视频或图片。
if parser.resourceDataProvider != nil {
respondWithProvidedDataOrFile(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
fileSize: fileSize
)
} else if let fileSize, fileSize > 524_288 {
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize) respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else { } else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask) respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
@@ -121,12 +135,75 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return size return size
} }
private func respondWithProvidedDataOrFile(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
fileSize: UInt64?
) {
ioQueue.async { [weak self] in
guard let self else { return }
guard self.isTaskActive(taskID) else { return }
if let provided = self.parser?.providedResourceData(at: fileURL) {
guard UInt64(provided.count) <= Self.maximumInMemoryResourceBytes else {
self.respondWithOversizeFailure(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
byteCount: UInt64(provided.count)
)
return
}
self.respondWithData(provided, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
} else if let fileSize, fileSize > 524_288 {
self.respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
self.respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, skipsProvider: true)
}
}
}
private func respondWithOversizeFailure(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
byteCount: UInt64
) {
RDEPUBWebViewDebug.logSchemeTask(
"ResourceScheme",
requestURL: requestURL,
fileURL: fileURL,
event: "rejected-oversize-\(byteCount)"
)
Self.recordFailure()
DispatchQueue.main.async { [weak self] in
guard let self, self.isTaskActive(taskID) else { return }
urlSchemeTask.didFailWithError(
NSError(
domain: NSURLErrorDomain,
code: NSURLErrorDataLengthExceedsMaximum,
userInfo: [NSLocalizedDescriptionKey: "资源超过单资源内存上限(\(byteCount) 字节)"]
)
)
self.clearTask(taskID)
}
}
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue, /// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
/// ensuring they run on the same serial queue that WebKit calls start() on. /// ensuring they run on the same serial queue that WebKit calls start() on.
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead /// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
/// of silently returning, satisfying the protocol requirement that every started /// of silently returning, satisfying the protocol requirement that every started
/// task must receive a completion callback. /// task must receive a completion callback.
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) { private func respondWithInMemoryData(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
skipsProvider: Bool = false
) {
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
guard let self else { guard let self else {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -137,7 +214,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
do { do {
// 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘 // 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘
let data: Data let data: Data
if let provided = self.parser?.providedResourceData(at: fileURL) { if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
data = provided data = provided
} else { } else {
data = try Data(contentsOf: fileURL) data = try Data(contentsOf: fileURL)
@@ -148,22 +225,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
return return
} }
let response = URLResponse( self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
self.dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
return return
} catch { } catch {
guard self.isTaskActive(taskID) else { guard self.isTaskActive(taskID) else {
@@ -182,6 +244,31 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
} }
private func respondWithData(
_ data: Data,
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask
) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) { private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
guard let self else { guard let self else {
@@ -157,6 +157,10 @@ public final class RDEPUBReaderController: UIViewController {
/// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本 /// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本
/// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。 /// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。
private var navigationBarHiddenBeforeReader: Bool? private var navigationBarHiddenBeforeReader: Bool?
/// 阅读器进入前宿主侧滑返回手势的真实状态。阅读器自己接管横向翻页,
/// 期间必须关闭该手势;退出时按原值恢复,不能把宿主的配置改成阅读器的。
private var interactivePopWasEnabledBeforeReader: Bool?
private weak var interactivePopDelegateBeforeReader: UIGestureRecognizerDelegate?
private(set) var isSearchBarVisible = false private(set) var isSearchBarVisible = false
@@ -287,9 +291,12 @@ public final class RDEPUBReaderController: UIViewController {
if navigationBarHiddenBeforeReader == nil { if navigationBarHiddenBeforeReader == nil {
navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
} }
if interactivePopWasEnabledBeforeReader == nil {
interactivePopWasEnabledBeforeReader = navigationController?.interactivePopGestureRecognizer?.isEnabled
interactivePopDelegateBeforeReader = navigationController?.interactivePopGestureRecognizer?.delegate
}
navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self navigationController?.interactivePopGestureRecognizer?.isEnabled = false
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
} }
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
@@ -300,6 +307,10 @@ public final class RDEPUBReaderController: UIViewController {
animated: animated animated: animated
) )
} }
if let interactivePopWasEnabledBeforeReader {
navigationController?.interactivePopGestureRecognizer?.delegate = interactivePopDelegateBeforeReader
navigationController?.interactivePopGestureRecognizer?.isEnabled = interactivePopWasEnabledBeforeReader
}
} }
public override func viewDidAppear(_ animated: Bool) { public override func viewDidAppear(_ animated: Bool) {
@@ -16,6 +16,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"]) private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
/// 空状态提示。放在 tableView 的子视图而不是 `backgroundView`:
/// backgroundView 不参与无障碍层级遍历,VoiceOver 与 UI 测试都读不到它。
private let emptyStateLabel = UILabel()
private var filteredHighlights: [RDEPUBHighlight] { private var filteredHighlights: [RDEPUBHighlight] {
switch filterControl.selectedSegmentIndex { switch filterControl.selectedSegmentIndex {
case 1: case 1:
@@ -120,19 +124,28 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
} }
private func updateEmptyState() { private func installEmptyStateLabelIfNeeded() {
guard filteredHighlights.isEmpty else { guard emptyStateLabel.superview == nil else { return }
tableView.backgroundView = nil emptyStateLabel.textAlignment = .center
return emptyStateLabel.numberOfLines = 0
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.accessibilityIdentifier = "epub.reader.highlights.empty"
emptyStateLabel.isAccessibilityElement = true
tableView.addSubview(emptyStateLabel)
// 固定在可视区域居中,不随内容滚动
NSLayoutConstraint.activate([
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
])
} }
let label = UILabel() private func updateEmptyState() {
label.text = emptyStateText() installEmptyStateLabelIfNeeded()
label.textAlignment = .center emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.textColor = theme.contentTextColor.withAlphaComponent(0.7) emptyStateLabel.text = emptyStateText()
label.numberOfLines = 0 emptyStateLabel.isHidden = !filteredHighlights.isEmpty
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
} }
@objc private func filterChangedAction() { @objc private func filterChangedAction() {
@@ -209,6 +222,9 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
return formatter return formatter
}() }()
/// 同 highlights 面板:不能用 `tableView.backgroundView`,那样无障碍读不到。
private let emptyStateLabel = UILabel()
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) { init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt } self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
self.theme = theme self.theme = theme
@@ -279,18 +295,27 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
} }
private func updateEmptyState() { private func installEmptyStateLabelIfNeeded() {
guard bookmarks.isEmpty == false else { guard emptyStateLabel.superview == nil else { return }
let label = UILabel() emptyStateLabel.text = "暂无书签"
label.text = "暂无书签" emptyStateLabel.textAlignment = .center
label.textAlignment = .center emptyStateLabel.numberOfLines = 0
label.textColor = theme.contentTextColor.withAlphaComponent(0.7) emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0 emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.empty"
label.accessibilityIdentifier = "epub.reader.bookmarks.empty" emptyStateLabel.isAccessibilityElement = true
tableView.backgroundView = label tableView.addSubview(emptyStateLabel)
return NSLayoutConstraint.activate([
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
])
} }
tableView.backgroundView = nil
private func updateEmptyState() {
installEmptyStateLabelIfNeeded()
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
emptyStateLabel.isHidden = !bookmarks.isEmpty
} }
private func titleText(for bookmark: RDEPUBBookmark) -> String { private func titleText(for bookmark: RDEPUBBookmark) -> String {
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation import Foundation
public protocol RDEPUBReaderPersistence: AnyObject { public protocol RDEPUBReaderPersistence: AnyObject {
@@ -47,6 +48,113 @@ public extension RDEPUBReaderPersistence {
} }
} }
/// 受文件保护的内容存储:高亮正文、批注、书签摘录属于书籍内容,不应明文
/// 落在 UserDefaults(默认不加密、会进备份)。这里改写到 Application Support
/// 下带 `NSFileProtection` 的文件,并排除 iCloud/iTunes 备份。
public final class RDEPUBProtectedContentStore {
private let directoryURL: URL
private let protection: FileProtectionType
private let fileManager = FileManager.default
private let namespace: String?
private let allowsLegacyFallback: Bool
/// 默认用 `.completeUntilFirstUserAuthentication`(首次解锁后可访问)而不是
/// `.complete`:两者都在磁盘上加密,但 `.complete` 会让锁屏期间的保存直接
/// 失败——阅读器在退到后台时可能正好赶上用户锁屏,那样会静默丢标注。
/// 对安全要求更高的宿主可以显式传入 `.complete`。
public init(
directoryName: String = "ssreaderview-epub/protected",
namespace: String? = nil,
allowsLegacyFallback: Bool = true,
protection: FileProtectionType = .completeUntilFirstUserAuthentication
) {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
self.directoryURL = base.appendingPathComponent(directoryName, isDirectory: true)
self.namespace = namespace
self.allowsLegacyFallback = allowsLegacyFallback
self.protection = protection
}
/// bookIdentifier 可能含 `/`、空格等非法文件名字符,且长度不可控——十六进制
/// 编码会翻倍,长书名很容易撞上 255 字节的文件名上限。用 SHA256 定长摘要。
private func fileURL(forKey key: String) -> URL {
let storageKey: String
if let namespace {
storageKey = namespace + "\u{0}" + key
} else {
storageKey = key
}
let digest = SHA256.hash(data: Data(storageKey.utf8))
let name = digest.map { String(format: "%02x", $0) }.joined()
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
}
private func legacyFileURL(forKey key: String) -> URL {
let digest = SHA256.hash(data: Data(key.utf8))
let name = digest.map { String(format: "%02x", $0) }.joined()
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
}
private func ensureDirectory() throws {
guard !fileManager.fileExists(atPath: directoryURL.path) else { return }
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: [.protectionKey: protection]
)
var url = directoryURL
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? url.setResourceValues(values)
}
public func read(forKey key: String) -> Data? {
if let data = try? Data(contentsOf: fileURL(forKey: key)) {
return data
}
guard namespace != nil, allowsLegacyFallback else { return nil }
return try? Data(contentsOf: legacyFileURL(forKey: key))
}
public func write(_ data: Data, forKey key: String) {
_ = writeIfPossible(data, forKey: key)
}
/// 返回写入是否完整成功。迁移旧数据时,只有成功落盘后才能删除原副本。
@discardableResult
func writeIfPossible(_ data: Data, forKey key: String) -> Bool {
do {
try ensureDirectory()
try data.write(to: fileURL(forKey: key), options: .atomic)
// atomic 写入会替换文件,保护属性需要写完再设一次
try fileManager.setAttributes([.protectionKey: protection], ofItemAtPath: fileURL(forKey: key).path)
return true
} catch {
#if DEBUG
print("[RDEPUBProtectedContentStore] ⚠️ Failed to write '\(key)': \(error)")
#endif
return false
}
}
public func removeValue(forKey key: String) {
try? fileManager.removeItem(at: fileURL(forKey: key))
if namespace != nil, allowsLegacyFallback {
try? fileManager.removeItem(at: legacyFileURL(forKey: key))
}
}
public func removeAll() {
try? fileManager.removeItem(at: directoryURL)
}
}
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -59,18 +167,80 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let settingsKey: String private let settingsKey: String
/// 高亮与书签(含书籍正文摘录)实际落盘的位置
private let contentStore: RDEPUBProtectedContentStore
public init( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.", locationPrefix: String = "ssreader.epub.location.",
bookmarksPrefix: String = "ssreader.epub.bookmarks.", bookmarksPrefix: String = "ssreader.epub.bookmarks.",
highlightsPrefix: String = "ssreader.epub.highlights.", highlightsPrefix: String = "ssreader.epub.highlights.",
settingsKey: String = "ssreader.epub.settings" settingsKey: String = "ssreader.epub.settings",
contentStore: RDEPUBProtectedContentStore? = nil
) { ) {
self.defaults = defaults self.defaults = defaults
self.locationPrefix = locationPrefix self.locationPrefix = locationPrefix
self.bookmarksPrefix = bookmarksPrefix self.bookmarksPrefix = bookmarksPrefix
self.highlightsPrefix = highlightsPrefix self.highlightsPrefix = highlightsPrefix
self.settingsKey = settingsKey self.settingsKey = settingsKey
if let contentStore {
self.contentStore = contentStore
} else {
let namespaceKey = settingsKey + ".protectedContentNamespace"
let isStandardDefaults = defaults === UserDefaults.standard
let namespace: String
if isStandardDefaults {
namespace = "standard"
} else if let storedNamespace = defaults.string(forKey: namespaceKey) {
namespace = storedNamespace
} else {
namespace = UUID().uuidString
defaults.set(namespace, forKey: namespaceKey)
}
self.contentStore = RDEPUBProtectedContentStore(
namespace: namespace,
// 旧版未分区文件无法区分来自哪个自定义 suite;只对标准容器保留迁移读取。
allowsLegacyFallback: isStandardDefaults
)
}
}
/// 从受保护存储读取;首次访问时把历史明文数据迁移过去并删掉明文副本。
private func loadContentData(key: String) -> Data? {
if let data = contentStore.read(forKey: key) {
return data
}
guard let legacy = defaults.data(forKey: key) else { return nil }
if contentStore.writeIfPossible(legacy, forKey: key) {
defaults.removeObject(forKey: key)
}
return legacy
}
private func saveContentData(_ data: Data, key: String) {
// 新副本确认落盘前保留历史数据,避免磁盘满或文件保护错误时丢失标注。
if contentStore.writeIfPossible(data, forKey: key),
defaults.object(forKey: key) != nil {
defaults.removeObject(forKey: key)
}
}
/// 清除某本书的阅读内容数据(高亮、书签、进度)。
public func clearReadingData(for bookIdentifier: String) {
contentStore.removeValue(forKey: highlightsPrefix + bookIdentifier)
contentStore.removeValue(forKey: bookmarksPrefix + bookIdentifier)
defaults.removeObject(forKey: highlightsPrefix + bookIdentifier)
defaults.removeObject(forKey: bookmarksPrefix + bookIdentifier)
defaults.removeObject(forKey: locationPrefix + bookIdentifier)
}
/// 清除全部书籍的高亮与书签内容数据,包括尚未迁移的历史 UserDefaults 数据。
public func clearAllReadingContent() {
contentStore.removeAll()
for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix(highlightsPrefix) || key.hasPrefix(bookmarksPrefix) {
defaults.removeObject(forKey: key)
}
} }
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? { public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
@@ -99,7 +269,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
} }
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else { guard let data = loadContentData(key: bookmarksPrefix + bookIdentifier) else {
return [] return []
} }
do { do {
@@ -115,7 +285,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
do { do {
let data = try JSONEncoder().encode(bookmarks) let data = try JSONEncoder().encode(bookmarks)
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier) saveContentData(data, key: bookmarksPrefix + bookIdentifier)
} catch { } catch {
#if DEBUG #if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)") print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
@@ -124,7 +294,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
} }
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] { public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else { guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else {
return [] return []
} }
do { do {
@@ -145,7 +315,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)") print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif #endif
} }
defaults.set(data, forKey: highlightsPrefix + bookIdentifier) saveContentData(data, key: highlightsPrefix + bookIdentifier)
} catch { } catch {
#if DEBUG #if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)") print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
@@ -65,12 +65,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private let emptyStateLabel = UILabel() private let emptyStateLabel = UILabel()
private let navigationRowView = UIView()
private let previousButton = RDEPUBReaderTintButton(type: .system) private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system) private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel() private let countLabel = UILabel()
/// Collapsed to 0 in the pristine state so the prompt keeps its original
/// spacing under the search field. See `updateNavigationRow(total:)`.
private var navigationRowHeightConstraint: NSLayoutConstraint?
private var searchSections: [RDEPUBReaderSearchSection] = [] private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = "" private var keyword = ""
@@ -85,7 +91,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
setupSubviews() setupSubviews()
setupConstraints() setupConstraints()
setupActions() setupActions()
updateLegacyNavigationEnabled(false) updateNavigationRow(total: 0)
showInitialState() showInitialState()
} }
@@ -94,20 +100,14 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
} }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the // Only claim touches that fall within the panelView. Taps on the dimmed
// accessibility-only navigation buttons. Taps on the dimmed scrim // scrim area pass through to the reader content below, allowing chrome
// area pass through to the reader content below, allowing chrome // toggle taps to work. The step controls live inside the panel, so they
// toggle taps to work. // no longer need a special case here.
let panelPoint = convert(point, to: panelView) let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, with: event) { if panelView.point(inside: panelPoint, with: event) {
return super.hitTest(point, with: event) return super.hitTest(point, with: event)
} }
// Also claim touches on the accessibility-only navigation buttons
if previousButton.frame.contains(point)
|| nextButton.frame.contains(point)
|| countLabel.frame.contains(point) {
return super.hitTest(point, with: event)
}
return nil return nil
} }
@@ -149,9 +149,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
navigationRowView.backgroundColor = rowColor
previousButton.tintColor = textColor previousButton.tintColor = textColor
nextButton.tintColor = textColor nextButton.tintColor = textColor
countLabel.textColor = textColor countLabel.textColor = secondaryTextColor
countLabel.backgroundColor = .clear countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
@@ -168,9 +169,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
} }
func updateMatchCount(current: Int, total: Int) { func updateMatchCount(current: Int, total: Int) {
// label 保持 "N/M" 原文:无障碍与 UI 测试都按这个格式读取,
// 覆盖 accessibilityLabel 会让 XCUIElement.label 拿不到计数。
countLabel.text = "\(current)/\(total)" countLabel.text = "\(current)/\(total)"
textField.accessibilityValue = "\(current)/\(total)" textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0) updateNavigationRow(total: total)
} }
func showNoResults() { func showNoResults() {
@@ -223,6 +226,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private func setupSubviews() { private func setupSubviews() {
backgroundButton.translatesAutoresizingMaskIntoConstraints = false backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false panelView.translatesAutoresizingMaskIntoConstraints = false
navigationRowView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -241,6 +245,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
panelView.addSubview(grabberView) panelView.addSubview(grabberView)
panelView.addSubview(searchRowView) panelView.addSubview(searchRowView)
panelView.addSubview(navigationRowView)
panelView.addSubview(tableView) panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel) panelView.addSubview(emptyStateLabel)
@@ -250,14 +255,15 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchFieldContainer.addSubview(searchIcon) searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField) searchFieldContainer.addSubview(textField)
addSubview(previousButton) navigationRowView.addSubview(countLabel)
addSubview(nextButton) navigationRowView.addSubview(previousButton)
addSubview(countLabel) navigationRowView.addSubview(nextButton)
if #available(iOS 13.0, *) { if #available(iOS 13.0, *) {
searchIcon.image = UIImage(systemName: "magnifyingglass") searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal) let stepSymbol = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal) previousButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: stepSymbol), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: stepSymbol), for: .normal)
} else { } else {
previousButton.setTitle("▲", for: .normal) previousButton.setTitle("▲", for: .normal)
nextButton.setTitle("▼", for: .normal) nextButton.setTitle("▼", for: .normal)
@@ -287,11 +293,16 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
nextButton.accessibilityIdentifier = "epub.reader.search.next" nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count" countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field" textField.accessibilityIdentifier = "epub.reader.search.field"
// 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于
// 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。 previousButton.accessibilityLabel = "上一个匹配"
previousButton.alpha = 0.02 nextButton.accessibilityLabel = "下一个匹配"
nextButton.alpha = 0.02
countLabel.alpha = 0.02 countLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
countLabel.textAlignment = .left
countLabel.isAccessibilityElement = true
navigationRowView.layer.cornerRadius = 18
navigationRowView.clipsToBounds = true
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier) tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self tableView.dataSource = self
@@ -345,33 +356,40 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18), cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor), cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
navigationRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
navigationRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
navigationRowView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 12),
countLabel.leadingAnchor.constraint(equalTo: navigationRowView.leadingAnchor, constant: 16),
countLabel.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
nextButton.trailingAnchor.constraint(equalTo: navigationRowView.trailingAnchor, constant: -8),
nextButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 44),
nextButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
nextButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
previousButton.trailingAnchor.constraint(equalTo: nextButton.leadingAnchor, constant: -4),
previousButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 44),
previousButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
previousButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
countLabel.trailingAnchor.constraint(lessThanOrEqualTo: previousButton.leadingAnchor, constant: -8),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0), tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0), tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18), tableView.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 12),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32), emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32), emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56), emptyStateLabel.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 50)
// 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图
// 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏
// 窗口拦截,永远到不了这些按钮。
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
]) ])
let heightConstraint = navigationRowView.heightAnchor.constraint(equalToConstant: 0)
heightConstraint.isActive = true
navigationRowHeightConstraint = heightConstraint
} }
private func setupActions() { private func setupActions() {
@@ -384,9 +402,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
} }
private func updateLegacyNavigationEnabled(_ enabled: Bool) { /// Stepping is only enabled once there are matches, but the row stays
previousButton.isEnabled = enabled /// visible (showing `0/0` with disabled arrows) for any keyword the user has
nextButton.isEnabled = enabled /// actually searched, so the result count is never silently missing. It
/// collapses away only in the pristine "type a keyword" state.
private func updateNavigationRow(total: Int) {
let hasMatches = total > 0
previousButton.isEnabled = hasMatches
nextButton.isEnabled = hasMatches
let isVisible = hasMatches || !keyword.isEmpty
navigationRowView.isHidden = !isVisible
navigationRowHeightConstraint?.constant = isVisible ? 36 : 0
} }
private func showInitialState() { private func showInitialState() {
@@ -225,7 +225,8 @@ public final class RDEpubURLReaderController: UIViewController {
emitDemoState() emitDemoState()
} }
private var readerController: RDEPUBReaderController? { /// 供 `Speech` 子模块桥接已嵌入的文本阅读器;不作为基础 Pod 的公开 API。
var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController embeddedController as? RDEPUBReaderController
} }
@@ -198,8 +198,7 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentHighlightsManager() { func presentHighlightsManager() {
guard let controller else { return } guard let controller else { return }
guard controller.configuration.allowsHighlights else { return } guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return } // 同 presentBookmarksManager:空列表照常呈现,面板有空状态提示。
let highlightsController = RDEPUBReaderHighlightsViewController( let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights, highlights: controller.activeHighlights,
theme: controller.configuration.theme, theme: controller.configuration.theme,
@@ -325,8 +324,8 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentBookmarksManager() { func presentBookmarksManager() {
guard let controller else { return } guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return } // 空列表也要照常呈现:面板本身有「暂无书签」空状态,早退会让点击书签
// 列表按钮什么反应都没有,用户无法区分「没有书签」和「功能坏了」。
let bookmarksController = RDEPUBReaderBookmarksViewController( let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks, bookmarks: controller.activeBookmarks,
theme: controller.configuration.theme theme: controller.configuration.theme
@@ -0,0 +1,130 @@
import Foundation
import RDSpeechReaderView
/// EPUB/TXT 朗读会话。它负责把通用朗读进度映射回阅读器的页码和临时高亮,
/// 但不会写入用户的高亮或批注持久化数据。
@MainActor
public final class RDEPUBSpeechSession: NSObject, RDSpeechReaderControllerDelegate {
public let controller: RDSpeechReaderController
public let contentProvider: RDEPUBSpeechContentProvider
public var onStateChange: ((RDSpeechReaderState) -> Void)?
private weak var reader: RDEPUBReaderController?
init(reader: RDEPUBReaderController, configuration: RDSpeechReaderConfiguration) {
self.reader = reader
contentProvider = reader.makeSpeechContentProvider()
controller = RDSpeechReaderController(contentProvider: contentProvider, configuration: configuration)
super.init()
controller.delegate = self
}
/// 默认从当前阅读位置所在章节开始;可传入已保存的朗读位置继续播放。
public func start(from location: RDSpeechLocation? = nil) async throws {
try await controller.start(from: location ?? currentSpeechLocation())
}
public func pause() { controller.pause() }
public func resume() { controller.resume() }
public func stop() {
controller.stop()
reader?.clearTransientHighlights()
}
public func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {
switch state {
case .idle, .finished, .failed:
reader?.clearTransientHighlights()
case .preparing, .speaking, .paused:
break
}
onStateChange?(state)
}
public func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {
guard let reader else { return }
let href = range.unit.location.resourceIdentifier
let absoluteRange = NSRange(
location: range.unit.textRange.location + range.range.location,
length: max(range.range.length, 1)
)
if let chapterData = reader.textChapterData(forNormalizedHref: href),
chapterData.attributedContent.length > 0 {
let boundedLocation = min(max(absoluteRange.location, 0), max(chapterData.attributedContent.length - 1, 0))
let boundedLength = min(max(absoluteRange.length, 1), max(chapterData.attributedContent.length - boundedLocation, 1))
let boundedRange = NSRange(location: boundedLocation, length: boundedLength)
if let page = chapterData.page(containing: boundedRange.location),
reader.currentPageNumber != page.absolutePageIndex + 1 {
_ = reader.go(toPageNumber: page.absolutePageIndex + 1, animated: false)
}
let highlight = RDEPUBHighlight(
id: "rd-speech-highlight",
bookIdentifier: reader.currentBookIdentifier,
location: chapterData.location(for: boundedRange, bookIdentifier: reader.currentBookIdentifier),
text: chapterData.attributedContent.attributedSubstring(from: boundedRange).string,
rangeInfo: RDEPUBTextOffsetRangeInfo(
href: chapterData.href,
start: boundedRange.location,
end: NSMaxRange(boundedRange)
).jsonString(),
style: .highlight,
color: "#86D7FF"
)
reader.setTransientHighlights([highlight])
return
}
// Web EPUB 不具备与原始 HTML 文本严格一致的原生文本索引。按章节定位,
// 并禁用临时文字高亮,避免把一个词级回调误判为多次页面跳转。
if reader.currentLocation?.href != href {
reader.go(to: RDEPUBLocation(
bookIdentifier: reader.currentBookIdentifier,
href: href,
progression: progression(for: range)
))
}
reader.clearTransientHighlights()
}
private func currentSpeechLocation() -> RDSpeechLocation? {
guard let reader,
let location = reader.currentLocation else {
return nil
}
let offset = reader.textChapterData(forNormalizedHref: location.href)
.flatMap { $0.absoluteRange(for: location)?.location }
?? 0
return RDSpeechLocation(
bookIdentifier: contentProvider.speechBookDescriptor().identifier,
resourceIdentifier: location.href,
textOffset: offset
)
}
private func progression(for range: RDSpeechSpokenRange) -> Double {
let totalLength = max(range.unit.textRange.location + range.unit.textRange.length, 1)
let offset = range.unit.textRange.location + range.range.location
return min(max(Double(offset) / Double(totalLength), 0), 1)
}
}
public extension RDEPUBReaderController {
func makeSpeechSession(
configuration: RDSpeechReaderConfiguration = .default
) -> RDEPUBSpeechSession {
RDEPUBSpeechSession(reader: self, configuration: configuration)
}
}
public extension RDEpubURLReaderController {
/// CBZ 等图片阅读器没有可朗读的文字阅读器时返回 `nil`。
func makeSpeechSession(
configuration: RDSpeechReaderConfiguration = .default
) -> RDEPUBSpeechSession? {
readerController.map { $0.makeSpeechSession(configuration: configuration) }
}
}
@@ -48,6 +48,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private var book: RDPDFReaderBookDescriptor private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:] private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// 已创建但不一定在屏幕上的页面视图。UIPageViewController 与 UICollectionView
/// 都会提前构建相邻页,这些视图在异步页面数据返回时还不在
/// `visiblePageContentViews()` 里;只刷新可见页会让它们翻到时停留在白页。
private let livePageViews = NSHashTable<RDPDFReaderPageView>.weakObjects()
/// Provider 不要求自行去重;同一页在旋转、cell 重建期间只能保留一个请求。 /// Provider 不要求自行去重;同一页在旋转、cell 重建期间只能保留一个请求。
private var pageDescriptorRequestTokens: [Int: UUID] = [:] private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:] private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
@@ -244,7 +248,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard !runs.isEmpty else { return [] } guard !runs.isEmpty else { return [] }
ocrRuns[pageIndex] = runs ocrRuns[pageIndex] = runs
ocrDiskCache?.save(runs, pageIndex: pageIndex) ocrDiskCache?.save(runs, pageIndex: pageIndex)
refreshVisiblePage(pageIndex) refreshPageViews(at: pageIndex)
return runs return runs
} }
@@ -265,14 +269,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) { public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return } guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects) speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
// `willSpeakRangeOfSpeechString` 会在同一句内多次回调。相同页面只需刷新
// 临时高亮,重复调用翻页入口会让仿真翻页在朗读过程中不断播放转场动画。
if currentPageIndex != pageIndex {
goToPage(pageIndex, animated: animated) goToPage(pageIndex, animated: animated)
refreshVisiblePage(pageIndex) }
refreshPageViews(at: pageIndex)
} }
public func clearSpeechHighlight() { public func clearSpeechHighlight() {
let highlightedPage = speechHighlight?.pageIndex let highlightedPage = speechHighlight?.pageIndex
speechHighlight = nil speechHighlight = nil
if let highlightedPage { refreshVisiblePage(highlightedPage) } if let highlightedPage { refreshPageViews(at: highlightedPage) }
} }
/// 首版画笔模式入口。横屏双页下左右内容页各自承载画布,笔迹严格裁剪在所属页内。 /// 首版画笔模式入口。横屏双页下左右内容页各自承载画布,笔迹严格裁剪在所属页内。
@@ -292,6 +300,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView { public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
let page = RDPDFReaderPageView() let page = RDPDFReaderPageView()
page.delegate = self page.delegate = self
// ReaderView 会在返回后再写一次 tag;这里提前写入,异步页面数据返回时
// 才能按页码找到尚未上屏的预建视图。
page.tag = pageNum
livePageViews.add(page)
configure(page, at: pageNum) configure(page, at: pageNum)
return page return page
} }
@@ -323,6 +335,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard pageNum >= 0 else { return } guard pageNum >= 0 else { return }
// OCR 队列是串行的。快速翻页后取消过期任务,避免停留页排在大量离开页之后。 // OCR 队列是串行的。快速翻页后取消过期任务,避免停留页排在大量离开页之后。
cancelOutstandingOCRRequests() cancelOutstandingOCRRequests()
// 翻到的页可能是提前构建的视图:它在离屏期间收到过页面数据,也可能因内存
// 警告被清掉了描述。上屏后重新装配一次,缺数据时由 configure 重新发起请求。
// 置于取消之后,避免刚排入的识别任务立刻被同一次调用清掉。
refreshPageViews(at: pageNum)
// 取消是全量的:双页的右页和竖滑可见邻页也被一并取消,必须重新排队, // 取消是全量的:双页的右页和竖滑可见邻页也被一并取消,必须重新排队,
// 否则它们在 cell 重建前一直没有文字层。当前页先入队以获得串行队列优先权。 // 否则它们在 cell 重建前一直没有文字层。当前页先入队以获得串行队列优先权。
startOCRIfNeeded(pageNum) startOCRIfNeeded(pageNum)
@@ -394,8 +410,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageDescriptors[index] = descriptor pageDescriptors[index] = descriptor
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。 // 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index) readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// 初始请求的 cell 可能已经因旋转或复用离开屏幕;只刷新仍显示的实际页面。 // 初始请求的 cell 可能已经因旋转或复用离开屏幕,也可能是仿真翻页提前构建、
refreshVisiblePage(index) // 尚未上屏的页;两者都要装配,否则翻到它时是白页。
refreshPageViews(at: index)
} }
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) { private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
@@ -404,7 +421,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
if attempt >= maximumPageDescriptorRequestAttempts { if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index) pageLoadFailedPages.insert(index)
} }
refreshVisiblePage(index) refreshPageViews(at: index)
} }
private func trimOCRCache(around pageIndex: Int, radius: Int) { private func trimOCRCache(around pageIndex: Int, radius: Int) {
@@ -474,7 +491,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
self.pageLoadFailedPages.insert(index) self.pageLoadFailedPages.insert(index)
} }
// 前两次超时会重新请求,第三次停止并展示手动重试入口。 // 前两次超时会重新请求,第三次停止并展示手动重试入口。
self.refreshVisiblePage(index) self.refreshPageViews(at: index)
} }
pageDescriptorRequestTimeouts[index] = timeout pageDescriptorRequestTimeouts[index] = timeout
let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0 let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0
@@ -482,7 +499,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageProvider.readerPage(at: index) { [weak self] descriptor in pageProvider.readerPage(at: index) { [weak self] descriptor in
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
guard descriptor.index == index else { // 没有图片的描述同样无法显示。缓存它只会得到一张永远不再重试的
// 白页,因此按无效结果处理:重试耗尽后给出可点击的重新加载入口。
guard descriptor.index == index, descriptor.image != nil else {
self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt) self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt)
return return
} }
@@ -550,8 +569,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
recognizingPages.remove(pageIndex) recognizingPages.remove(pageIndex)
ocrRuns[pageIndex] = runs ocrRuns[pageIndex] = runs
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) } if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
// 只有仍在屏幕上的页会被重新配置;快速翻过的页面不抢占当前页 UI 更新。 // 只重新配置该页自己的视图,快速翻过的其它页面不抢占当前页 UI 更新。
refreshVisiblePage(pageIndex) refreshPageViews(at: pageIndex)
} }
private func cancelOutstandingOCRRequests() { private func cancelOutstandingOCRRequests() {
@@ -560,9 +579,19 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
ocrRequestTokens.removeAll() ocrRequestTokens.removeAll()
} }
private func refreshVisiblePage(_ index: Int) { /// 重新装配某一页的全部活动视图。仿真翻页的相邻页由 UIPageViewController 预先
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return } /// 构建,收到页面数据时还不在 `viewControllers` 中;只按“可见”查找会漏掉它们,
configure(page, at: index) /// 翻页后就会显示白页。
private func refreshPageViews(at index: Int) {
var targets: [RDPDFReaderPageView] = []
if let displayed = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView {
targets.append(displayed)
}
for page in livePageViews.allObjects
where page.tag == index && !targets.contains(where: { $0 === page }) {
targets.append(page)
}
targets.forEach { configure($0, at: index) }
} }
/// 屏幕上所有内容页视图。双页模式下 `visiblePageContentViews` 返回的是 spread /// 屏幕上所有内容页视图。双页模式下 `visiblePageContentViews` 返回的是 spread
@@ -821,7 +850,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
view.backgroundColor = theme.contentBackgroundColor view.backgroundColor = theme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor readerView.backgroundColor = pageSurroundingColor
applyChromeTheme() applyChromeTheme()
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() } if readerView.currentPage >= 0 { refreshPageViews(at: readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
} }
private func applyChromeTheme() { private func applyChromeTheme() {
@@ -834,7 +863,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)) _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache() reloadAnnotationCache()
refreshVisiblePage(page) refreshPageViews(at: page)
} }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -843,7 +872,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id) _ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache() reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex) refreshPageViews(at: annotation.pageIndex)
} }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -855,7 +884,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) } public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) } public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) } public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshVisiblePage(item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } } public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshPageViews(at: item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) { private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) {
let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) }) let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) })
@@ -869,7 +898,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try self?.annotationPersistence?.updateAnnotation(item) _ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache() self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex) self?.refreshPageViews(at: item.pageIndex)
} catch { } catch {
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -45,7 +45,9 @@ public final class RDPDFSpeechSession: NSObject, RDSpeechReaderControllerDelegat
guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return } guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return }
reader?.showSpeechHighlight( reader?.showSpeechHighlight(
pageIndex: pageIndex, pageIndex: pageIndex,
normalizedRects: contentProvider.normalizedRects(for: range) normalizedRects: contentProvider.normalizedRects(for: range),
// 朗读跨页时只需同步定位,不能把词级高亮变成一次页面转场。
animated: false
) )
} }
} }
+5 -5
View File
@@ -37,10 +37,10 @@ try await session.start(from: 0)
## Use with EPUB ## Use with EPUB
```swift ```swift
let provider = reader.makeSpeechContentProvider() let session = reader.makeSpeechSession()
let speech = RDSpeechReaderController(contentProvider: provider) try await session.start()
try await speech.start(from: nil)
``` ```
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable EPUB/TXT 会话默认从当前阅读位置开始。文本重排 EPUB 与 TXT 会临时高亮当前朗读范围,
when the user changes fonts or page size, unlike screen page numbers. 并且只在朗读跨页时定位;朗读位置使用 EPUB `href` 与 UTF-16 文本偏移,因此用户修改
字体或页面大小后仍可稳定恢复。
@@ -20,11 +20,16 @@ public final class RDSpeechReaderController: NSObject {
private var queuedUnits: [RDSpeechTextUnit] = [] private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = [] private var completedUnits: [RDSpeechTextUnit] = []
private var nextLocation: RDSpeechLocation? private var nextLocation: RDSpeechLocation?
private var isLoadingBatch = false private var loadingGeneration: UInt?
private var playbackGeneration: UInt = 0
private var activeUtterance: AVSpeechUtterance?
private var isStopping = false private var isStopping = false
private var shouldResumeAfterInterruption = false private var shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>? private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = [] private var notificationTokens: [NSObjectProtocol] = []
private var remoteCommandTargets: [(command: MPRemoteCommand, token: Any)] = []
private static weak var activeRemoteController: RDSpeechReaderController?
public init( public init(
contentProvider: RDSpeechContentProvider, contentProvider: RDSpeechContentProvider,
@@ -42,24 +47,25 @@ public final class RDSpeechReaderController: NSObject {
deinit { deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver) notificationTokens.forEach(NotificationCenter.default.removeObserver)
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil) remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
} }
public func start(from location: RDSpeechLocation? = nil) async throws { public func start(from location: RDSpeechLocation? = nil) async throws {
stop(clearProgress: false) playbackGeneration &+= 1
let generation = playbackGeneration
resetPlayback(clearProgress: false)
isStopping = false isStopping = false
completedUnits.removeAll() completedUnits.removeAll()
claimRemoteControlsIfNeeded()
state = .preparing state = .preparing
do { do {
if configuration.configuresAudioSession { try configureAudioSession() } if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true) try await loadBatch(startingAt: initialLocation, requiresContent: true, generation: generation)
try await speakNextUnit() guard isCurrentPlaybackGeneration(generation) else { return }
try await speakNextUnit(generation: generation)
} catch { } catch {
guard isCurrentPlaybackGeneration(generation) else { return }
state = .failed(error.localizedDescription) state = .failed(error.localizedDescription)
throw error throw error
} }
@@ -72,31 +78,40 @@ public final class RDSpeechReaderController: NSObject {
public func resume() { public func resume() {
guard synthesizer.isPaused else { return } guard synthesizer.isPaused else { return }
claimRemoteControlsIfNeeded()
synthesizer.continueSpeaking() synthesizer.continueSpeaking()
} }
public func stop() { stop(clearProgress: false) } public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) { public func stop(clearProgress: Bool) {
playbackGeneration &+= 1
resetPlayback(clearProgress: clearProgress)
state = .idle
}
private func resetPlayback(clearProgress: Bool) {
isStopping = true isStopping = true
synthesizer.stopSpeaking(at: .immediate) synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
currentUnit = nil currentUnit = nil
queuedUnits.removeAll() queuedUnits.removeAll()
nextLocation = nil nextLocation = nil
isLoadingBatch = false loadingGeneration = nil
sleepTimerTask?.cancel() sleepTimerTask?.cancel()
sleepTimerTask = nil sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) } if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
state = .idle
} }
public func skipToNextSentence() { public func skipToNextSentence() {
guard state.isActive else { return } guard state.isActive else { return }
let generation = playbackGeneration
stopCurrentUtteranceForNavigation() stopCurrentUtteranceForNavigation()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { try await self.speakNextUnit() } do { try await self.speakNextUnit(generation: generation) }
catch { self.state = .failed(error.localizedDescription) } catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
} }
} }
@@ -143,18 +158,26 @@ public final class RDSpeechReaderController: NSObject {
} }
} }
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws { private func loadBatch(
guard !isLoadingBatch, !isStopping else { return } startingAt location: RDSpeechLocation?,
isLoadingBatch = true requiresContent: Bool,
defer { isLoadingBatch = false } generation: UInt
) async throws {
guard loadingGeneration != generation, isCurrentPlaybackGeneration(generation) else { return }
loadingGeneration = generation
defer {
if loadingGeneration == generation {
loadingGeneration = nil
}
}
var requestedLocation = location var requestedLocation = location
var mustContainContent = requiresContent var mustContainContent = requiresContent
while !isStopping { while isCurrentPlaybackGeneration(generation) {
let batch = try await contentProvider.speechContentBatch( let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation, startingAt: requestedLocation,
limit: configuration.batchSize limit: configuration.batchSize
) )
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
if !batch.units.isEmpty { if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units) queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation nextLocation = batch.nextLocation
@@ -171,12 +194,12 @@ public final class RDSpeechReaderController: NSObject {
} }
} }
private func speakNextUnit() async throws { private func speakNextUnit(generation: UInt) async throws {
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
if queuedUnits.isEmpty, let nextLocation { if queuedUnits.isEmpty, let nextLocation {
try await loadBatch(startingAt: nextLocation, requiresContent: false) try await loadBatch(startingAt: nextLocation, requiresContent: false, generation: generation)
} }
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
guard !queuedUnits.isEmpty else { guard !queuedUnits.isEmpty else {
if state != .finished { if state != .finished {
state = .finished state = .finished
@@ -193,6 +216,7 @@ public final class RDSpeechReaderController: NSObject {
let utterance = AVSpeechUtterance(string: unit.text) let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit) utterance.voice = resolvedVoice(for: unit)
activeUtterance = utterance
synthesizer.speak(utterance) synthesizer.speak(utterance)
} }
@@ -208,9 +232,14 @@ public final class RDSpeechReaderController: NSObject {
private func stopCurrentUtteranceForNavigation() { private func stopCurrentUtteranceForNavigation() {
isStopping = true isStopping = true
synthesizer.stopSpeaking(at: .immediate) synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
isStopping = false isStopping = false
} }
private func isCurrentPlaybackGeneration(_ generation: UInt) -> Bool {
playbackGeneration == generation && !isStopping
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? { private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier, if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) { let voice = AVSpeechSynthesisVoice(identifier: identifier) {
@@ -266,33 +295,43 @@ public final class RDSpeechReaderController: NSObject {
commands.togglePlayPauseCommand.isEnabled = true commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.isEnabled = true commands.nextTrackCommand.isEnabled = true
commands.previousTrackCommand.isEnabled = true commands.previousTrackCommand.isEnabled = true
commands.playCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.playCommand, commands.playCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.resume() } Task { @MainActor [weak self] in self?.resume() }
return .success return .success
} }))
commands.pauseCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.pauseCommand, commands.pauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.pause() } Task { @MainActor [weak self] in self?.pause() }
return .success return .success
} }))
commands.togglePlayPauseCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.togglePlayPauseCommand, commands.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause() self.synthesizer.isPaused ? self.resume() : self.pause()
} }
return .success return .success
} }))
commands.nextTrackCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.nextTrackCommand, commands.nextTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToNextSentence() } Task { @MainActor [weak self] in self?.skipToNextSentence() }
return .success return .success
} }))
commands.previousTrackCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.previousTrackCommand, commands.previousTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToPreviousSentence() } Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
return .success return .success
}))
} }
private func claimRemoteControlsIfNeeded() {
guard configuration.enablesRemoteControls else { return }
Self.activeRemoteController = self
} }
private func updateNowPlayingInfo() { private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls else { return } guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
let book = contentProvider.speechBookDescriptor() let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [ var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title, MPMediaItemPropertyTitle: book.title,
@@ -305,7 +344,7 @@ public final class RDSpeechReaderController: NSObject {
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate { extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidStart() } Task { @MainActor [weak self] in self?.handleDidStart(utterance) }
} }
public nonisolated func speechSynthesizer( public nonisolated func speechSynthesizer(
@@ -313,55 +352,58 @@ extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
willSpeakRangeOfSpeechString characterRange: NSRange, willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance utterance: AVSpeechUtterance
) { ) {
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) } Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange, utterance: utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidPause() } Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidContinue() } Task { @MainActor [weak self] in self?.handleDidContinue(utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidFinish() } Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
} }
} }
private extension RDSpeechReaderController { private extension RDSpeechReaderController {
func handleDidStart() { func handleDidStart(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier) progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location) state = .speaking(currentUnit.location)
} }
func handleWillSpeak(_ characterRange: NSRange) { func handleWillSpeak(_ characterRange: NSRange, utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
var location = currentUnit.location var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier) progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange)) delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
} }
func handleDidPause() { func handleDidPause(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
state = .paused(currentUnit.location) state = .paused(currentUnit.location)
} }
func handleDidContinue() { func handleDidContinue(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
state = .speaking(currentUnit.location) state = .speaking(currentUnit.location)
} }
func handleDidFinish() { func handleDidFinish(_ utterance: AVSpeechUtterance) {
guard !isStopping, let finished = currentUnit else { return } guard utterance === activeUtterance, !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished) completedUnits.append(finished)
activeUtterance = nil
currentUnit = nil currentUnit = nil
let generation = playbackGeneration
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { try await self.speakNextUnit() } do { try await self.speakNextUnit(generation: generation) }
catch { self.state = .failed(error.localizedDescription) } catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
} }
} }
} }