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