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 格式 |
|------|----------|
| 阅读位置 | `ssreader.epub.location.{bookID}` |
| 书签 | `ssreader.epub.bookmarks.{bookID}` |
| 高亮 | `ssreader.epub.highlights.{bookID}` |
| 书签 | 受保护文件(历史键:`ssreader.epub.bookmarks.{bookID}` |
| 高亮 | 受保护文件(历史键:`ssreader.epub.highlights.{bookID}` |
| 全局设置 | `ssreader.epub.settings` |
---
+12
View File
@@ -1,6 +1,18 @@
# 代码库风险与关注点
**分析日期:** 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 | 阅读位置持久化 |
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
| PageNavigationTests.swift | 页面导航 |
| PDFPageTurnBlankPageTests.swift | PDF 翻页后白页回归(截屏墨迹占比判定) |
| ReaderAnnotationTests.swift | 标注基础功能 |
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
@@ -72,6 +73,12 @@ cd ReadViewDemo && pod install
(依赖入口:`ReadViewDemo/Podfile`
### 模拟器要求
Demo 链接了 `FoundationModels.framework`(AI 助手示例入口)。该框架自 iOS 26 起提供,
在 iOS 18 及更早的模拟器上 App 会在启动时因 `Library not loaded` 直接崩溃,测试表现为
卡在 Launch 阶段。请选择 iOS 26 及以上的模拟器运行 UI 测试。
### 运行 UI 测试
```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 测试文件清单、运行方式、覆盖率 |
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
+8 -1
View File
@@ -17,8 +17,15 @@ target 'ReadViewDemo' do
end
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|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c
RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c
PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
COCOAPODS: 1.16.2
@@ -43,7 +43,7 @@
"~> 0.1"
]
},
"frameworks": "FoundationModels"
"weak_frameworks": "FoundationModels"
},
{
"name": "UI",
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c
RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c
PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
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"
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
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_SWIFT_FLAGS = $(inherited) -D COCOAPODS
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"
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
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_SWIFT_FLAGS = $(inherited) -D COCOAPODS
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
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
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
PODS_BUILD_DIR = ${BUILD_DIR}
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 */; };
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.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 */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
/* End PBXBuildFile section */
@@ -95,6 +96,7 @@
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>"; };
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>"; };
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>"; };
@@ -213,6 +215,7 @@
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */,
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */,
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */,
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */,
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */,
@@ -394,6 +397,7 @@
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */,
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */,
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */,
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */,
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */,
@@ -452,7 +456,6 @@
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -477,7 +480,6 @@
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -504,7 +506,6 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -543,7 +544,6 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -608,7 +608,6 @@
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -671,7 +670,6 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -152,6 +152,9 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
/// Demo 宿
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
/// AI TTS 便
private static let showsAIAssistant = false
private static let showsSpeechControls = false
private let reader: RDPDFReaderViewController
private lazy var speechSession: RDPDFSpeechSession = {
let session = reader.makeSpeechSession()
@@ -224,15 +227,17 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(reader.view)
reader.didMove(toParent: self)
configureSpeechControls()
let assistantButton = UIButton(type: .system)
assistantButton.setTitle("AI", for: .normal)
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside)
assistantButton.accessibilityLabel = "打开阅读助手"
view.addSubview(assistantButton)
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)])
if Self.showsSpeechControls { configureSpeechControls() }
if Self.showsAIAssistant {
let assistantButton = UIButton(type: .system)
assistantButton.setTitle("AI", for: .normal)
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside)
assistantButton.accessibilityLabel = "打开阅读助手"
view.addSubview(assistantButton)
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)])
}
stateLabel.translatesAutoresizingMaskIntoConstraints = false
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)])
@@ -353,6 +353,9 @@ final class ViewController: UIViewController {
defaults.removeObject(forKey: key)
}
}
// / UserDefaults
// defaults
RDEPUBProtectedContentStore().removeAll()
PDFDemoReaderViewController.resetPersistedAnnotations()
}
@@ -74,10 +74,13 @@ enum IDs {
static let searchClose = "epub.reader.search.close"
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 settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
/// SDK font.increase / font.decrease
static let settingsFontSize = "epub.reader.settings.font.size"
static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice"
static let settingsLineHeight = "epub.reader.settings.lineHeight"
@@ -169,4 +169,74 @@ extension XCUIApplication {
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
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
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮不存在")
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
RunLoop.current.run(until: Date().addingTimeInterval(0.6))
// Close settings
let doneButton = app.buttons[IDs.settingsDone]
@@ -41,9 +41,10 @@ final class DisplayTypeTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap()
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
XCTAssertTrue(displayTypeControl.waitForExistence(timeout: 5))
displayTypeControl.buttons.element(boundBy: 2).tap()
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "竖滑"),
"翻页方式应可切换到竖滑"
)
app.buttons[IDs.settingsDone].tap()
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 }
}
func testSettingsPanelScrollReachesAllControls() throws {
func testSettingsPanelReachesAllControls() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let settingsScroll = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
settingsScroll.swipeUp()
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons[IDs.settingsDisplayType].waitForExistence(timeout: 5))
settingsScroll.swipeUp()
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
app.buttons[IDs.settingsDone].tap()
@@ -8,7 +8,9 @@ final class LocationPersistenceTests: XCTestCase {
}
func testReadingPositionRestoredOnReopen() throws {
app.launchAndOpenSampleBook()
// 仿 `epub.reader.paging`
// collectionViewswipeLeft 西 1
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader(timeout: 12)
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
@@ -30,7 +32,8 @@ final class LocationPersistenceTests: XCTestCase {
let backButton = app.buttons[IDs.readerBack]
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
app.launchAndOpenSampleBook()
// --demo-reset-state
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: false)
app.waitForReader(timeout: 12)
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
@@ -61,7 +64,7 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded()
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() }
let backButton = app.buttons[IDs.readerBack]
@@ -74,7 +77,8 @@ final class LocationPersistenceTests: XCTestCase {
}
func testPositionSurvivesFontChoiceAndLineHeightChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
//
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging]
@@ -97,17 +101,14 @@ final class LocationPersistenceTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
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.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
"字体选择控件应可切换"
)
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准"),
"行距控件应可切换"
)
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3))
app.buttons[IDs.settingsDone].tap()
@@ -131,8 +132,9 @@ final class LocationPersistenceTests: XCTestCase {
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
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 end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
@@ -174,11 +176,10 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
"字体选择控件应可切换"
)
app.buttons[IDs.settingsDone].tap()
let content = app.otherElements[IDs.readerContent].firstMatch
@@ -92,10 +92,8 @@ final class MemoryFootprintTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
app.buttons[IDs.settingsFontIncrease].tap()
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
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))
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
fontIncrease.tap()
fontIncrease.tap()
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
@@ -367,10 +364,7 @@ final class SearchTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
@@ -114,10 +114,7 @@ final class SelectionAnnotateTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
@@ -16,9 +16,8 @@ final class SettingsEffectTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块不存在")
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap()
@@ -32,8 +31,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
app.scrollViews[IDs.settingsScroll].swipeUp()
XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
@@ -50,12 +48,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
XCTAssertTrue(app.waitForSettingsPanel())
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: 1).tap()
}
app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准")
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
@@ -68,12 +63,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
XCTAssertTrue(app.waitForSettingsPanel())
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
displayTypeControl.buttons.element(boundBy: 0).tap()
}
app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "仿真")
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
@@ -98,19 +90,21 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
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]
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
let originalFontValue = fontValueLabel.label
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
let newFontValue = fontValueLabel.label
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大字号 label 应变化")
XCTAssertNotEqual(newFontValue, originalFontValue, "增大字号 label 应变化")
app.buttons[IDs.settingsDone].tap()
app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook()
// resetsReaderState
//
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
@@ -158,11 +152,12 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
let fontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5))
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
"字体应可切换到宋体"
)
XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新")
app.buttons[IDs.settingsDone].tap()
@@ -174,7 +169,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let reopenedFontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
let reopenedFontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5))
XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持")
app.buttons[IDs.settingsDone].tap()
@@ -14,7 +14,7 @@ final class SettingsExtendedTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
XCTAssertTrue(app.waitForSettingsPanel())
}
func testBrightnessSliderExists() {
@@ -25,12 +25,13 @@ final class SettingsExtendedTests: XCTestCase {
func testLineHeightControl() {
openSettings()
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
lineHeight.buttons["紧凑"].tap()
lineHeight.buttons["标准"].tap()
lineHeight.buttons["宽松"].tap()
for title in ["紧凑", "标准", "宽松"] {
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: title),
"行距应可切换到\(title)"
)
}
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
@@ -38,10 +39,11 @@ final class SettingsExtendedTests: XCTestCase {
func testDisplayTypeFromSettings() {
openSettings()
let displayType = app.segmentedControls[IDs.settingsDisplayType]
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现")
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "横滑"),
"翻页方式应可切换到横滑"
)
displayType.buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
}
@@ -54,13 +56,13 @@ final class SettingsExtendedTests: XCTestCase {
}
}
func testScrollSettingsToReachAllControls() {
///
func testAllSettingsControlsReachableWithoutScrolling() {
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.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons[IDs.settingsLineHeight].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))
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
XCTAssertTrue(app.waitForSettingsPanel())
let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap()
let fontChoice = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoice.waitForExistence(timeout: 3))
fontChoice.buttons["宋体"].tap()
XCTAssertTrue(app.decreaseSettingsFontSize(), "字号滑块应存在")
XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
"字体应可切换到宋体"
)
let darkTheme = app.buttons[IDs.settingsTheme(5)]
XCTAssertTrue(darkTheme.waitForExistence(timeout: 3))
@@ -26,7 +26,10 @@ Pod::Spec.new do |s|
s.subspec "FoundationModels" do |foundation_models|
foundation_models.source_files = "FoundationModels/**/*.swift"
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
s.subspec "UI" do |ui|
@@ -25,12 +25,22 @@ extension RDEPUBParser {
}
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 extractionURL = temporaryExtractionDirectory(for: epubURL)
if fileManager.fileExists(atPath: extractionURL.path) {
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
if fileManager.fileExists(atPath: containerURL.path) {
// 访 LRU FIFO
Self.touchExtractionDirectory(extractionURL)
return extractionURL
}
try? fileManager.removeItem(at: extractionURL)
@@ -67,6 +77,8 @@ extension RDEPUBParser {
throw error
}
Self.touchExtractionDirectory(extractionURL)
Self.pruneExtractionCache(keeping: extractionURL)
return extractionURL
}
@@ -107,9 +119,7 @@ extension RDEPUBParser {
}
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
let baseURL = Self.extractionCacheRootURL
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "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(set) var rootFilePath: String?
@@ -12,6 +12,11 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
public static let host = "book"
///
///
/// provider
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
private weak var parser: RDEPUBParser?
private let fileManager = FileManager.default
@@ -87,9 +92,18 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
let fileSize = resourceSize(for: fileURL)
// provider
// provider
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
// Provider nil IO
// provider
//
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)
} else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
@@ -121,12 +135,75 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
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,
/// ensuring they run on the same serial queue that WebKit calls start() on.
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
/// of silently returning, satisfying the protocol requirement that every started
/// 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
guard let self else {
DispatchQueue.main.async {
@@ -137,7 +214,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
do {
// provider provider
let data: Data
if let provided = self.parser?.providedResourceData(at: fileURL) {
if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
data = provided
} else {
data = try Data(contentsOf: fileURL)
@@ -148,22 +225,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
return
}
let response = URLResponse(
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")
self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
return
} catch {
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) {
ioQueue.async { [weak self] in
guard let self else {
@@ -157,6 +157,10 @@ public final class RDEPUBReaderController: UIViewController {
/// 宿使
/// 退
private var navigationBarHiddenBeforeReader: Bool?
/// 宿
/// 退宿
private var interactivePopWasEnabledBeforeReader: Bool?
private weak var interactivePopDelegateBeforeReader: UIGestureRecognizerDelegate?
private(set) var isSearchBarVisible = false
@@ -287,9 +291,12 @@ public final class RDEPUBReaderController: UIViewController {
if navigationBarHiddenBeforeReader == nil {
navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
}
if interactivePopWasEnabledBeforeReader == nil {
interactivePopWasEnabledBeforeReader = navigationController?.interactivePopGestureRecognizer?.isEnabled
interactivePopDelegateBeforeReader = navigationController?.interactivePopGestureRecognizer?.delegate
}
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
public override func viewWillDisappear(_ animated: Bool) {
@@ -300,6 +307,10 @@ public final class RDEPUBReaderController: UIViewController {
animated: animated
)
}
if let interactivePopWasEnabledBeforeReader {
navigationController?.interactivePopGestureRecognizer?.delegate = interactivePopDelegateBeforeReader
navigationController?.interactivePopGestureRecognizer?.isEnabled = interactivePopWasEnabledBeforeReader
}
}
public override func viewDidAppear(_ animated: Bool) {
@@ -16,6 +16,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
/// tableView `backgroundView`
/// backgroundView VoiceOver UI
private let emptyStateLabel = UILabel()
private var filteredHighlights: [RDEPUBHighlight] {
switch filterControl.selectedSegmentIndex {
case 1:
@@ -120,19 +124,28 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
}
private func updateEmptyState() {
guard filteredHighlights.isEmpty else {
tableView.backgroundView = nil
return
}
private func installEmptyStateLabelIfNeeded() {
guard emptyStateLabel.superview == nil else { return }
emptyStateLabel.textAlignment = .center
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()
label.text = emptyStateText()
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
private func updateEmptyState() {
installEmptyStateLabelIfNeeded()
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
emptyStateLabel.text = emptyStateText()
emptyStateLabel.isHidden = !filteredHighlights.isEmpty
}
@objc private func filterChangedAction() {
@@ -209,6 +222,9 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
return formatter
}()
/// highlights `tableView.backgroundView`
private let emptyStateLabel = UILabel()
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
self.theme = theme
@@ -279,18 +295,27 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
}
private func installEmptyStateLabelIfNeeded() {
guard emptyStateLabel.superview == nil else { return }
emptyStateLabel.text = "暂无书签"
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.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)
])
}
private func updateEmptyState() {
guard bookmarks.isEmpty == false else {
let label = UILabel()
label.text = "暂无书签"
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
tableView.backgroundView = label
return
}
tableView.backgroundView = nil
installEmptyStateLabelIfNeeded()
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
emptyStateLabel.isHidden = !bookmarks.isEmpty
}
private func titleText(for bookmark: RDEPUBBookmark) -> String {
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
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 {
private let defaults: UserDefaults
@@ -59,18 +167,80 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let settingsKey: String
///
private let contentStore: RDEPUBProtectedContentStore
public init(
defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.",
bookmarksPrefix: String = "ssreader.epub.bookmarks.",
highlightsPrefix: String = "ssreader.epub.highlights.",
settingsKey: String = "ssreader.epub.settings"
settingsKey: String = "ssreader.epub.settings",
contentStore: RDEPUBProtectedContentStore? = nil
) {
self.defaults = defaults
self.locationPrefix = locationPrefix
self.bookmarksPrefix = bookmarksPrefix
self.highlightsPrefix = highlightsPrefix
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? {
@@ -99,7 +269,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
}
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 []
}
do {
@@ -115,7 +285,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
do {
let data = try JSONEncoder().encode(bookmarks)
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
saveContentData(data, key: bookmarksPrefix + bookIdentifier)
} catch {
#if DEBUG
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] {
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else {
return []
}
do {
@@ -145,7 +315,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
saveContentData(data, key: highlightsPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
@@ -65,12 +65,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private let emptyStateLabel = UILabel()
private let navigationRowView = UIView()
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
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 keyword = ""
@@ -85,7 +91,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
setupSubviews()
setupConstraints()
setupActions()
updateLegacyNavigationEnabled(false)
updateNavigationRow(total: 0)
showInitialState()
}
@@ -94,20 +100,14 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the
// accessibility-only navigation buttons. Taps on the dimmed scrim
// area pass through to the reader content below, allowing chrome
// toggle taps to work.
// Only claim touches that fall within the panelView. Taps on the dimmed
// scrim area pass through to the reader content below, allowing chrome
// toggle taps to work. The step controls live inside the panel, so they
// no longer need a special case here.
let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, 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
}
@@ -149,9 +149,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
navigationRowView.backgroundColor = rowColor
previousButton.tintColor = textColor
nextButton.tintColor = textColor
countLabel.textColor = textColor
countLabel.textColor = secondaryTextColor
countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
@@ -168,9 +169,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}
func updateMatchCount(current: Int, total: Int) {
// label "N/M" UI
// accessibilityLabel XCUIElement.label
countLabel.text = "\(current)/\(total)"
textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0)
updateNavigationRow(total: total)
}
func showNoResults() {
@@ -223,6 +226,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private func setupSubviews() {
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false
navigationRowView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -241,6 +245,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
panelView.addSubview(grabberView)
panelView.addSubview(searchRowView)
panelView.addSubview(navigationRowView)
panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel)
@@ -250,14 +255,15 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
navigationRowView.addSubview(countLabel)
navigationRowView.addSubview(previousButton)
navigationRowView.addSubview(nextButton)
if #available(iOS 13.0, *) {
searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
let stepSymbol = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
previousButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: stepSymbol), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: stepSymbol), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
@@ -287,11 +293,16 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
// 0.02 0.01CALayer.opacity Float320.01
// 0.01 UIKit
previousButton.alpha = 0.02
nextButton.alpha = 0.02
countLabel.alpha = 0.02
previousButton.accessibilityLabel = "上一个匹配"
nextButton.accessibilityLabel = "下一个匹配"
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.dataSource = self
@@ -345,33 +356,40 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
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.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),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
// /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)
emptyStateLabel.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 50)
])
let heightConstraint = navigationRowView.heightAnchor.constraint(equalToConstant: 0)
heightConstraint.isActive = true
navigationRowHeightConstraint = heightConstraint
}
private func setupActions() {
@@ -384,9 +402,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
nextButton.isEnabled = enabled
/// Stepping is only enabled once there are matches, but the row stays
/// visible (showing `0/0` with disabled arrows) for any keyword the user has
/// 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() {
@@ -225,7 +225,8 @@ public final class RDEpubURLReaderController: UIViewController {
emitDemoState()
}
private var readerController: RDEPUBReaderController? {
/// `Speech` Pod API
var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
@@ -198,8 +198,7 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return }
// presentBookmarksManager
let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights,
theme: controller.configuration.theme,
@@ -325,8 +324,8 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
// 退
//
let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks,
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 currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// UIPageViewController UICollectionView
///
/// `visiblePageContentViews()`
private let livePageViews = NSHashTable<RDPDFReaderPageView>.weakObjects()
/// Provider cell
private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
@@ -244,7 +248,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard !runs.isEmpty else { return [] }
ocrRuns[pageIndex] = runs
ocrDiskCache?.save(runs, pageIndex: pageIndex)
refreshVisiblePage(pageIndex)
refreshPageViews(at: pageIndex)
return runs
}
@@ -265,14 +269,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
goToPage(pageIndex, animated: animated)
refreshVisiblePage(pageIndex)
// `willSpeakRangeOfSpeechString`
// 仿
if currentPageIndex != pageIndex {
goToPage(pageIndex, animated: animated)
}
refreshPageViews(at: pageIndex)
}
public func clearSpeechHighlight() {
let highlightedPage = speechHighlight?.pageIndex
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 {
let page = RDPDFReaderPageView()
page.delegate = self
// ReaderView tag
//
page.tag = pageNum
livePageViews.add(page)
configure(page, at: pageNum)
return page
}
@@ -323,6 +335,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard pageNum >= 0 else { return }
// OCR
cancelOutstandingOCRRequests()
//
// configure
//
refreshPageViews(at: pageNum)
//
// cell
startOCRIfNeeded(pageNum)
@@ -394,8 +410,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageDescriptors[index] = descriptor
// cell
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// cell
refreshVisiblePage(index)
// cell 仿
//
refreshPageViews(at: index)
}
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
@@ -404,7 +421,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index)
}
refreshVisiblePage(index)
refreshPageViews(at: index)
}
private func trimOCRCache(around pageIndex: Int, radius: Int) {
@@ -474,7 +491,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
self.pageLoadFailedPages.insert(index)
}
//
self.refreshVisiblePage(index)
self.refreshPageViews(at: index)
}
pageDescriptorRequestTimeouts[index] = timeout
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
DispatchQueue.main.async { [weak self] in
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)
return
}
@@ -550,8 +569,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
recognizingPages.remove(pageIndex)
ocrRuns[pageIndex] = runs
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
// UI
refreshVisiblePage(pageIndex)
// UI
refreshPageViews(at: pageIndex)
}
private func cancelOutstandingOCRRequests() {
@@ -560,9 +579,19 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
ocrRequestTokens.removeAll()
}
private func refreshVisiblePage(_ index: Int) {
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return }
configure(page, at: index)
/// 仿 UIPageViewController
/// `viewControllers`
///
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
@@ -821,7 +850,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
view.backgroundColor = theme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor
applyChromeTheme()
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
if readerView.currentPage >= 0 { refreshPageViews(at: readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
}
private func applyChromeTheme() {
@@ -834,7 +863,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache()
refreshVisiblePage(page)
refreshPageViews(at: page)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
@@ -843,7 +872,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex)
refreshPageViews(at: annotation.pageIndex)
}
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, 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, 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) {
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 {
_ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex)
self?.refreshPageViews(at: item.pageIndex)
} catch {
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 }
reader?.showSpeechHighlight(
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
```swift
let provider = reader.makeSpeechContentProvider()
let speech = RDSpeechReaderController(contentProvider: provider)
try await speech.start(from: nil)
let session = reader.makeSpeechSession()
try await session.start()
```
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable
when the user changes fonts or page size, unlike screen page numbers.
EPUB/TXT 会话默认从当前阅读位置开始。文本重排 EPUB 与 TXT 会临时高亮当前朗读范围,
并且只在朗读跨页时定位;朗读位置使用 EPUB `href` 与 UTF-16 文本偏移,因此用户修改
字体或页面大小后仍可稳定恢复。
@@ -20,11 +20,16 @@ public final class RDSpeechReaderController: NSObject {
private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = []
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 shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = []
private var remoteCommandTargets: [(command: MPRemoteCommand, token: Any)] = []
private static weak var activeRemoteController: RDSpeechReaderController?
public init(
contentProvider: RDSpeechContentProvider,
@@ -42,24 +47,25 @@ public final class RDSpeechReaderController: NSObject {
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
}
public func start(from location: RDSpeechLocation? = nil) async throws {
stop(clearProgress: false)
playbackGeneration &+= 1
let generation = playbackGeneration
resetPlayback(clearProgress: false)
isStopping = false
completedUnits.removeAll()
claimRemoteControlsIfNeeded()
state = .preparing
do {
if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true)
try await speakNextUnit()
try await loadBatch(startingAt: initialLocation, requiresContent: true, generation: generation)
guard isCurrentPlaybackGeneration(generation) else { return }
try await speakNextUnit(generation: generation)
} catch {
guard isCurrentPlaybackGeneration(generation) else { return }
state = .failed(error.localizedDescription)
throw error
}
@@ -72,31 +78,40 @@ public final class RDSpeechReaderController: NSObject {
public func resume() {
guard synthesizer.isPaused else { return }
claimRemoteControlsIfNeeded()
synthesizer.continueSpeaking()
}
public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) {
playbackGeneration &+= 1
resetPlayback(clearProgress: clearProgress)
state = .idle
}
private func resetPlayback(clearProgress: Bool) {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
currentUnit = nil
queuedUnits.removeAll()
nextLocation = nil
isLoadingBatch = false
loadingGeneration = nil
sleepTimerTask?.cancel()
sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
state = .idle
}
public func skipToNextSentence() {
guard state.isActive else { return }
let generation = playbackGeneration
stopCurrentUtteranceForNavigation()
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
do { try await self.speakNextUnit(generation: generation) }
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 {
guard !isLoadingBatch, !isStopping else { return }
isLoadingBatch = true
defer { isLoadingBatch = false }
private func loadBatch(
startingAt location: RDSpeechLocation?,
requiresContent: Bool,
generation: UInt
) async throws {
guard loadingGeneration != generation, isCurrentPlaybackGeneration(generation) else { return }
loadingGeneration = generation
defer {
if loadingGeneration == generation {
loadingGeneration = nil
}
}
var requestedLocation = location
var mustContainContent = requiresContent
while !isStopping {
while isCurrentPlaybackGeneration(generation) {
let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation,
limit: configuration.batchSize
)
guard !isStopping else { return }
guard isCurrentPlaybackGeneration(generation) else { return }
if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation
@@ -171,12 +194,12 @@ public final class RDSpeechReaderController: NSObject {
}
}
private func speakNextUnit() async throws {
guard !isStopping else { return }
private func speakNextUnit(generation: UInt) async throws {
guard isCurrentPlaybackGeneration(generation) else { return }
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 {
if state != .finished {
state = .finished
@@ -193,6 +216,7 @@ public final class RDSpeechReaderController: NSObject {
let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit)
activeUtterance = utterance
synthesizer.speak(utterance)
}
@@ -208,9 +232,14 @@ public final class RDSpeechReaderController: NSObject {
private func stopCurrentUtteranceForNavigation() {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
isStopping = false
}
private func isCurrentPlaybackGeneration(_ generation: UInt) -> Bool {
playbackGeneration == generation && !isStopping
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
@@ -266,33 +295,43 @@ public final class RDSpeechReaderController: NSObject {
commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.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() }
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() }
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
guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause()
}
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() }
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() }
return .success
}
}))
}
private func claimRemoteControlsIfNeeded() {
guard configuration.enablesRemoteControls else { return }
Self.activeRemoteController = self
}
private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls else { return }
guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title,
@@ -305,7 +344,7 @@ public final class RDSpeechReaderController: NSObject {
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
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(
@@ -313,55 +352,58 @@ extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
willSpeakRangeOfSpeechString characterRange: NSRange,
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) {
Task { @MainActor [weak self] in self?.handleDidPause() }
Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
}
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) {
Task { @MainActor [weak self] in self?.handleDidFinish() }
Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
}
}
private extension RDSpeechReaderController {
func handleDidStart() {
guard let currentUnit else { return }
func handleDidStart(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location)
}
func handleWillSpeak(_ characterRange: NSRange) {
guard let currentUnit else { return }
func handleWillSpeak(_ characterRange: NSRange, utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
}
func handleDidPause() {
guard let currentUnit else { return }
func handleDidPause(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .paused(currentUnit.location)
}
func handleDidContinue() {
guard let currentUnit else { return }
func handleDidContinue(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .speaking(currentUnit.location)
}
func handleDidFinish() {
guard !isStopping, let finished = currentUnit else { return }
func handleDidFinish(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished)
activeUtterance = nil
currentUnit = nil
let generation = playbackGeneration
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
do { try await self.speakNextUnit(generation: generation) }
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
}
}
}