Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift
T
shenlei 95cead5863 修复 PDF 阅读交互稳定性并统一项目技能管理
PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。

验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。
2026-07-24 14:40:02 +09:00

841 lines
43 KiB
Swift

import UIKit
import SnapKit
/// 成品 PDF 阅读控制器。自定义加密文件由宿主提供页面;普通 PDF 可由 SDK 使用
/// PDFKit 直接解析。两种来源共用阅读交互、OCR、标注及面板。
public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate {
public struct Configuration {
public var displayType: RDPDFReaderView.DisplayType = .pageCurl
/// 默认与 EPUB 成品控制器一致:横屏仿真/横滑使用双页,竖滑保持单页。
public var landscapeDualPageEnabled = true
public var themes: [RDPDFReaderThemeOption] = RDPDFReaderThemeOption.defaultPresets
public var initialThemeIdentifier: Int = 0
public var enablesOCR = true
public var recognitionLanguages: [String] = []
/// 将 Vision 识别结果写入系统可清除的缓存目录;再次打开同一书籍时可直接复用。
public var cachesOCRResultsOnDisk = true
/// 书籍内容、识别语言或 OCR 算法策略变更时递增,以使旧缓存自动失效。
public var ocrDiskCacheVersion = 1
/// 将 PDFKit 原生文本与坐标写入系统可清除的缓存目录。
public var cachesNativeTextResultsOnDisk = true
/// PDF 内容、裁剪框规则或原生文本坐标转换策略变更时递增。
public var nativeTextDiskCacheVersion = 1
/// 未提供文字且 OCR 关闭时,页面仍可使用区域标注。
public var missingTextSource: RDPDFReaderAnnotationSource = .region
/// 控制器默认只保留当前页及前后各两页的页面描述。SDK 直读 PDF 时,内置
/// Provider 的页面大图缓存也会同步收窄到该窗口;离开窗口的页面按需重绘。
public var pageDescriptorCacheRadius = 2
/// 内存中保留当前页前后的 OCR 结果数量;默认沿用页面描述缓存半径。
public var ocrMemoryCacheRadius: Int?
/// 宿主或内置 Provider 提供页面后,可在此进行反色或其它主题渲染。
public var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
public init() {}
}
public weak var delegate: RDPDFReaderViewControllerDelegate?
public let pageProvider: RDPDFReaderPageProvider
public weak var persistence: RDPDFReaderPersistence?
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
public private(set) var configuration: Configuration
private let readerView = RDPDFReaderView()
private let recognizer: RDPDFReaderImageTextRecognizer
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// Provider 不要求自行去重;同一页在旋转、cell 重建期间只能保留一个请求。
private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
private var pageDescriptorRequestAttempts: [Int: Int] = [:]
private var pageLoadFailedPages = Set<Int>()
private let maximumPageDescriptorRequestAttempts = 3
/// 避免 OCR、主题和标注刷新同一页面时反复同步读取笔迹 JSON。
private var drawingDocuments: [Int: RDPDFReaderDrawingDocument] = [:]
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
private var recognizingPages = Set<Int>()
/// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。
private var ocrRequestTokens: [Int: UUID] = [:]
private var bookmarks = Set<Int>()
private var annotations = [RDPDFReaderAnnotation]()
private var annotationsByPage: [Int: [RDPDFReaderAnnotation]] = [:]
private weak var topToolbar: RDPDFReaderKitTopToolView?
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
private var navigationLoadingIndicator: UIActivityIndicatorView?
private var navigationRequestToken: UUID?
private let drawingToolbar = RDPDFReaderDrawingToolbar()
private var isDrawingMode = false
/// `nil` 时处于画笔面板内的浏览状态,可单指拖动画面。
private var currentDrawingTool: RDPDFReaderDrawingTool?
private var currentDrawingColor = UIColor.black
private var currentDrawingLineWidth: CGFloat = 4
private weak var continuedDrawingPage: RDPDFReaderPageView?
/// 图层面板始终针对最后一次绘制的实际页;双页场景下不会误操作另一页。
private weak var activeDrawingPage: RDPDFReaderPageView?
/// 用户在可切换状态下选择的阅读方式;手机横屏仅临时覆盖为竖滑。
private var preferredDisplayType: RDPDFReaderView.DisplayType
/// 旋转动画期间不能依赖尚未更新的 view.bounds,提前锁定目标方向以避免先建出双页。
private var phoneLandscapeTransitionOverride: Bool?
public init(
pageProvider: RDPDFReaderPageProvider,
persistence: RDPDFReaderPersistence? = nil,
annotationPersistence: RDPDFReaderAnnotationPersisting? = nil,
configuration: Configuration = .init()
) {
precondition(!configuration.themes.isEmpty, "至少需要一个 PDF 阅读主题")
self.pageProvider = pageProvider
self.persistence = persistence
self.annotationPersistence = annotationPersistence
self.configuration = configuration
book = pageProvider.readerBookDescriptor()
currentTheme = configuration.themes.first { $0.identifier == configuration.initialThemeIdentifier } ?? configuration.themes[0]
preferredDisplayType = configuration.displayType
recognizer = RDPDFReaderImageTextRecognizer(recognitionLanguages: configuration.recognitionLanguages)
ocrDiskCache = configuration.cachesOCRResultsOnDisk
? RDPDFReaderTextRunDiskCache(
bookIdentifier: book.identifier,
cacheVersion: configuration.ocrDiskCacheVersion,
namespace: "vision-ocr",
profile: "\(configuration.recognitionLanguages.sorted().joined(separator: ","))|accurate-1"
)
: nil
super.init(nibName: nil, bundle: nil)
title = book.title
}
/// 直接打开普通 PDF。标准 PDF 密码可通过 `password` 传入;自定义加密文件应继续
/// 使用 `init(pageProvider:...)`,由宿主解密后提供页面图片。
public convenience init(
pdfURL: URL,
bookIdentifier: String? = nil,
title: String? = nil,
password: String? = nil,
persistence: RDPDFReaderPersistence? = nil,
annotationPersistence: RDPDFReaderAnnotationPersisting? = nil,
configuration: Configuration = .init()
) throws {
let provider = try RDPDFKitPageProvider(
pdfURL: pdfURL,
bookIdentifier: bookIdentifier,
title: title,
password: password,
cachesTextRunsOnDisk: configuration.cachesNativeTextResultsOnDisk,
textRunDiskCacheVersion: configuration.nativeTextDiskCacheVersion
)
self.init(
pageProvider: provider,
persistence: persistence,
annotationPersistence: annotationPersistence,
configuration: configuration
)
}
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = currentTheme.contentBackgroundColor
readerView.backgroundColor = currentTheme.contentBackgroundColor
readerView.frame = view.bounds
readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
readerView.dataSource = self
readerView.delegate = self
reloadAnnotationCache()
readerView.currentDisplayType = configuration.displayType
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
// 手机横屏竖滑的 cell 高度 = 安全区内可用宽度按页面纵横比换算的纸张高度。
readerView.verticalScrollWidthFitPageHeightProvider = { [weak self] pageNum, width in
guard let self,
let imageSize = self.pageDescriptors[pageNum]?.image?.size,
imageSize.width > 0, imageSize.height > 0 else { return nil }
let insets = self.view.safeAreaInsets
let availableWidth = max(1, width - insets.left - insets.right)
return availableWidth * imageSize.height / imageSize.width
}
view.addSubview(readerView)
readerView.reloadData()
applyDisplayTypeForCurrentInterface()
if let page = persistence?.restoreReadingPage(for: book.identifier), page >= 0, page < book.totalPages {
readerView.transitionToPage(pageNum: page, animated: false)
}
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 宿主或另一阅读器实例可能已修改同一本书的标注;重新出现时刷新快照。
reloadAnnotations()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelOutstandingOCRRequests()
// 保留仍在屏幕上的页:描述被清掉后没有任何路径会重新请求它,
// 当前页会因取不到 image 而永远无法恢复选字/划线。
let visibleIndexes = Set(visiblePageViews().map(\.tag))
pageDescriptors = pageDescriptors.filter { visibleIndexes.contains($0.key) }
ocrRuns = ocrRuns.filter { visibleIndexes.contains($0.key) }
drawingDocuments = drawingDocuments.filter { visibleIndexes.contains($0.key) }
(pageProvider as? RDPDFKitPageProvider)?.removeCachedImages()
visiblePageViews().forEach { $0.releaseDrawingRenderingCache() }
visibleIndexes.sorted().forEach { startOCRIfNeeded($0) }
}
public func switchDisplayType(_ type: RDPDFReaderView.DisplayType) {
preferredDisplayType = type
applyDisplayTypeForCurrentInterface()
}
/// 重新读取宿主的标注数据,并刷新当前可见页。宿主完成云同步或外部编辑后可调用。
public func reloadAnnotations() {
reloadAnnotationCache()
guard isViewLoaded else { return }
visiblePageViews().forEach { configure($0, at: $0.tag) }
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
let forcePhoneLandscape = traitCollection.userInterfaceIdiom == .phone && size.width > size.height
phoneLandscapeTransitionOverride = forcePhoneLandscape
coordinator.animate(alongsideTransition: { [weak self] _ in
// 在 ReaderView 根据新尺寸重排前完成模式切换,杜绝横屏先闪现双页。
self?.applyDisplayTypeForCurrentInterface()
}) { [weak self] _ in
guard let self else { return }
self.phoneLandscapeTransitionOverride = nil
self.applyDisplayTypeForCurrentInterface()
}
}
public func goToPage(_ pageIndex: Int, animated: Bool = false) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
readerView.transitionToPage(pageNum: pageIndex, animated: animated)
}
/// 首版画笔模式入口。横屏双页下左右内容页各自承载画布,笔迹严格裁剪在所属页内。
public func setDrawingMode(_ enabled: Bool) {
guard isDrawingMode != enabled else { return }
isDrawingMode = enabled
if enabled {
installDrawingToolbar()
} else {
drawingToolbar.removeFromSuperview()
}
updateDrawingInteractionState()
}
public func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { book.totalPages }
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
let page = RDPDFReaderPageView()
page.delegate = self
configure(page, at: pageNum)
return page
}
public func topToolView(readerView: RDPDFReaderView) -> UIView? {
let toolbar = RDPDFReaderKitTopToolView()
toolbar.onBack = { [weak self] in
guard let self else { return }
self.delegate?.pdfReaderViewControllerDidRequestClose(self)
}
toolbar.onToggleBookmark = { [weak self] in self?.toggleBookmark() }
topToolbar = toolbar
applyChromeTheme()
return toolbar
}
public func bottomToolView(readerView: RDPDFReaderView) -> UIView? {
let toolbar = RDPDFReaderKitBottomToolView()
toolbar.onShowTableOfContents = { [weak self] in self?.showNavigation() }
toolbar.onShowAnnotations = { [weak self] in self?.showAnnotations() }
toolbar.onStartDrawing = { [weak self] in self?.setDrawingMode(true) }
toolbar.onShowSettings = { [weak self] in self?.showSettings() }
bottomToolbar = toolbar
applyChromeTheme()
return toolbar
}
public func pageNum(readerView: RDPDFReaderView, pageNum: Int) {
guard pageNum >= 0 else { return }
// OCR 队列是串行的。快速翻页后取消过期任务,避免停留页排在大量离开页之后。
cancelOutstandingOCRRequests()
// 取消是全量的:双页的右页和竖滑可见邻页也被一并取消,必须重新排队,
// 否则它们在 cell 重建前一直没有文字层。当前页先入队以获得串行队列优先权。
startOCRIfNeeded(pageNum)
visiblePageViews().map(\.tag).filter { $0 != pageNum }.sorted().forEach { startOCRIfNeeded($0) }
trimOCRCache(
around: pageNum,
radius: configuration.ocrMemoryCacheRadius ?? configuration.pageDescriptorCacheRadius
)
trimPageDescriptorCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius)
trimDrawingDocumentCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius)
(pageProvider as? RDPDFKitPageProvider)?.trimPageImages(
around: pageNum,
radius: configuration.pageDescriptorCacheRadius
)
(pageProvider as? RDPDFKitPageProvider)?.trimTextRuns(
around: pageNum,
radius: configuration.pageDescriptorCacheRadius
)
title = "PDF · \(pageNum + 1) / \(book.totalPages)"
topToolbar?.setTitle(title ?? book.title)
topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum))
persistence?.saveReadingPage(pageNum, for: book.identifier)
delegate?.pdfReaderViewController(self, didChangePage: pageNum)
}
private func trimPageDescriptorCache(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
let visiblePages = Set(visiblePageViews().map(\.tag))
let isStale: (Int) -> Bool = { abs($0 - pageIndex) > safeRadius && !visiblePages.contains($0) }
pageDescriptors.keys.filter(isStale).forEach { pageDescriptors.removeValue(forKey: $0) }
// Provider 本身无法取消时,以 token 失效保证迟到回调不能把远页重新塞回缓存。
pageDescriptorRequestTokens.keys.filter(isStale).forEach { clearPageDescriptorRequest(for: $0) }
pageDescriptorRequestAttempts.keys.filter(isStale).forEach { pageDescriptorRequestAttempts.removeValue(forKey: $0) }
pageLoadFailedPages = pageLoadFailedPages.filter { !isStale($0) }
}
private func trimDrawingDocumentCache(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
drawingDocuments = drawingDocuments.filter { abs($0.key - pageIndex) <= safeRadius }
}
private func shouldRetainPageDescriptor(_ pageIndex: Int) -> Bool {
if visiblePageViews().contains(where: { $0.tag == pageIndex }) { return true }
let currentPage = readerView.currentPage
return currentPage >= 0 && abs(pageIndex - currentPage) <= max(0, configuration.pageDescriptorCacheRadius)
}
private func drawingDocument(for pageIndex: Int) -> RDPDFReaderDrawingDocument {
if let cached = drawingDocuments[pageIndex] { return cached }
let document = (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: pageIndex)
?? .init(pageNo: pageIndex, paths: [])
drawingDocuments[pageIndex] = document
return document
}
private func cacheDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageIndex: Int) {
drawingDocuments[pageIndex] = document
}
private func removePageDescriptorRequestState(for index: Int) {
clearPageDescriptorRequest(for: index)
pageDescriptorRequestAttempts.removeValue(forKey: index)
pageLoadFailedPages.remove(index)
}
private func acceptPageDescriptor(_ descriptor: RDPDFReaderPageDescriptor, at index: Int) {
removePageDescriptorRequestState(for: index)
pageDescriptors[index] = descriptor
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// 初始请求的 cell 可能已经因旋转或复用离开屏幕;只刷新仍显示的实际页面。
refreshVisiblePage(index)
}
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
guard pageDescriptorRequestTokens[index] == token else { return }
clearPageDescriptorRequest(for: index)
if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index)
}
refreshVisiblePage(index)
}
private func trimOCRCache(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
let stalePages = ocrRuns.keys.filter { abs($0 - pageIndex) > safeRadius }
stalePages.forEach { ocrRuns.removeValue(forKey: $0) }
}
private func configure(_ page: RDPDFReaderPageView, at index: Int) {
let descriptor = pageDescriptors[index]
page.image = descriptor.flatMap { renderedImage($0.image) }
page.applyTheme(contentBackgroundColor: currentTheme.contentBackgroundColor, surroundingBackgroundColor: pageSurroundingColor)
page.setPhoneLandscapeWidthFitting(isPhoneLandscape, safeAreaInsets: view.safeAreaInsets)
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
let source: RDPDFReaderAnnotationSource = descriptor?.textRuns != nil ? .text : (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
page.configureTextLayer(pageIndex: index, textRuns: runs, textSource: source, annotations: annotations(for: index))
page.isDrawingSessionActive = isDrawingMode
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
page.configureDrawing(
pageIndex: index,
document: drawingDocument(for: index),
documentChanged: { [weak self, weak page] document in
guard let self,
let store = self.annotationPersistence as? RDPDFReaderPersistenceStore else { return }
switch store.saveDrawingDocumentVersioned(document, pageNo: index) {
case .success(let saved):
self.cacheDrawingDocument(saved, pageIndex: index)
page?.acknowledgeDrawingRevision(saved.revision)
case .failure(let error):
self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error)
}
}
)
if let currentDrawingTool {
page.setDrawingTool(currentDrawingTool, color: currentDrawingColor, lineWidth: currentDrawingLineWidth)
}
page.drawingStrokeEventHandler = { [weak self] source, point, phase in
if phase == .began { self?.activeDrawingPage = source }
self?.routeDrawingStroke(from: source, point: point, phase: phase)
}
guard descriptor == nil else {
page.setPageLoadFailed(false)
startOCRIfNeeded(index)
return
}
if pageLoadFailedPages.contains(index) {
page.setPageLoadFailed(true) { [weak self, weak page] in
guard let self, let page else { return }
self.pageLoadFailedPages.remove(index)
self.pageDescriptorRequestAttempts[index] = 0
page.setPageLoadFailed(false)
self.configure(page, at: index)
}
return
}
page.setPageLoadFailed(false)
guard pageDescriptorRequestTokens[index] == nil else { return }
let token = UUID()
let attempt = (pageDescriptorRequestAttempts[index] ?? 0) + 1
pageDescriptorRequestAttempts[index] = attempt
pageDescriptorRequestTokens[index] = token
let timeout = DispatchWorkItem { [weak self] in
guard let self, self.pageDescriptorRequestTokens[index] == token else { return }
self.clearPageDescriptorRequest(for: index)
if attempt >= self.maximumPageDescriptorRequestAttempts {
self.pageLoadFailedPages.insert(index)
}
// 前两次超时会重新请求,第三次停止并展示手动重试入口。
self.refreshVisiblePage(index)
}
pageDescriptorRequestTimeouts[index] = timeout
let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeout)
pageProvider.readerPage(at: index) { [weak self] descriptor in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard descriptor.index == index else {
self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt)
return
}
// 超时只代表响应慢,不代表结果失效。只要页面仍在缓存窗口且尚无更新
// 结果,就接受迟到的有效描述;若新重试已开始,它会随 token 一并失效。
guard self.pageDescriptors[index] == nil else {
if self.pageDescriptorRequestTokens[index] == token {
self.clearPageDescriptorRequest(for: index)
}
return
}
guard self.shouldRetainPageDescriptor(index) else {
if self.pageDescriptorRequestTokens[index] == token {
self.clearPageDescriptorRequest(for: index)
}
return
}
self.acceptPageDescriptor(descriptor, at: index)
}
}
}
private func clearPageDescriptorRequest(for index: Int) {
pageDescriptorRequestTokens.removeValue(forKey: index)
pageDescriptorRequestTimeouts.removeValue(forKey: index)?.cancel()
}
private func renderedImage(_ image: UIImage?) -> UIImage? {
guard let image else { return nil }
return configuration.pageImageTransform?(image, currentTheme) ?? image
}
private func startOCRIfNeeded(_ index: Int) {
guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil,
!recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return }
recognizingPages.insert(index)
let token = UUID()
ocrRequestTokens[index] = token
ocrDiskCache?.load(pageIndex: index) { [weak self] cachedRuns in
guard let self, self.ocrRequestTokens[index] == token else { return }
if let cachedRuns {
self.completeOCR(cachedRuns, pageIndex: index, token: token, shouldPersist: false)
return
}
self.recognizer.recognizeTextRuns(in: image) { [weak self] runs in
self?.completeOCR(runs, pageIndex: index, token: token, shouldPersist: true)
}
}
// 没有磁盘缓存时直接入队,避免等待一个永远不会调用的读取回调。
if ocrDiskCache == nil {
recognizer.recognizeTextRuns(in: image) { [weak self] runs in
self?.completeOCR(runs, pageIndex: index, token: token, shouldPersist: false)
}
}
}
private func completeOCR(
_ runs: [RDPDFReaderTextRun],
pageIndex: Int,
token: UUID,
shouldPersist: Bool
) {
guard ocrRequestTokens[pageIndex] == token else { return }
ocrRequestTokens.removeValue(forKey: pageIndex)
recognizingPages.remove(pageIndex)
ocrRuns[pageIndex] = runs
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
// 只有仍在屏幕上的页会被重新配置;快速翻过的页面不抢占当前页 UI 更新。
refreshVisiblePage(pageIndex)
}
private func cancelOutstandingOCRRequests() {
recognizer.cancelAllRequests()
recognizingPages.removeAll()
ocrRequestTokens.removeAll()
}
private func refreshVisiblePage(_ index: Int) {
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return }
configure(page, at: index)
}
/// 屏幕上所有内容页视图。双页模式下 `visiblePageContentViews` 返回的是 spread
/// 容器,需要拆出左右两个内容页。
private func visiblePageViews() -> [RDPDFReaderPageView] {
readerView.visiblePageContentViews()
.flatMap { view -> [UIView] in
if let spread = view as? RDPDFReaderPageSpreadView { return spread.pageViews }
return [view]
}
.compactMap { $0 as? RDPDFReaderPageView }
.filter { $0.tag >= 0 }
}
private func annotations(for index: Int) -> [RDPDFReaderAnnotation] { annotationsByPage[index] ?? [] }
private func reloadAnnotationCache() {
guard let annotationPersistence else {
annotations = []
annotationsByPage = [:]
return
}
do {
annotations = try annotationPersistence.loadAnnotations()
annotationsByPage = Dictionary(grouping: annotations, by: \.pageIndex)
} catch {
annotations = []
annotationsByPage = [:]
delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error)
}
}
private func toggleBookmark() {
let page = readerView.currentPage
guard page >= 0 else { return }
let marked = !bookmarks.contains(page)
if marked { bookmarks.insert(page) } else { bookmarks.remove(page) }
persistence?.setBookmark(marked, pageIndex: page, for: book.identifier)
topToolbar?.setBookmarkSelected(marked)
}
private func showNavigation() {
if let asyncOutlineProvider = pageProvider as? RDPDFReaderAsyncOutlineProviding {
guard navigationLoadingIndicator == nil else { return }
let token = UUID()
navigationRequestToken = token
showNavigationLoading()
asyncOutlineProvider.readerOutlineItems { [weak self] outline in
DispatchQueue.main.async {
guard let self else { return }
guard self.navigationRequestToken == token else { return }
self.hideNavigationLoading()
self.presentNavigation(outline: outline)
}
}
return
}
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems()
?? (0..<book.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
presentNavigation(outline: outline)
}
private func presentNavigation(outline: [RDPDFReaderOutlineItem]) {
let marks = bookmarks.sorted().map { RDPDFReaderBookmark(pageIndex: $0, title: "第 \($0 + 1) 页") }
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: outline, bookmarks: marks, totalPages: book.totalPages, thumbnailProvider: { [weak self] index, size, completion in
guard let self else {
DispatchQueue.main.async { completion(nil) }
return
}
self.pageProvider.readerThumbnail(at: index, targetSize: size) { image in
DispatchQueue.main.async { completion(image) }
}
}, onSelectPage: { [weak self] page in self?.readerView.transitionToPage(pageNum: page, animated: false); self?.readerView.hideToolViewIfNeeded() })
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation)
}
private func showNavigationLoading() {
guard navigationLoadingIndicator == nil else { return }
let indicator = UIActivityIndicatorView(style: .large)
indicator.hidesWhenStopped = true
view.addSubview(indicator)
indicator.snp.makeConstraints { $0.center.equalToSuperview() }
indicator.startAnimating()
navigationLoadingIndicator = indicator
}
private func hideNavigationLoading() {
navigationLoadingIndicator?.stopAnimating()
navigationLoadingIndicator?.removeFromSuperview()
navigationLoadingIndicator = nil
navigationRequestToken = nil
}
private func showSettings() {
let panel = RDPDFReaderSettingsPanelViewController(
displayType: readerView.currentDisplayType,
brightness: UIScreen.main.brightness,
themes: configuration.themes,
selectedThemeIdentifier: currentTheme.identifier,
allowsDisplayTypeSelection: !isPhoneLandscape
)
panel.onBrightnessChange = { UIScreen.main.brightness = $0 }
panel.onDisplayTypeChange = { [weak self] type in self?.switchDisplayType(type) }
panel.onThemeChange = { [weak self] theme in self?.apply(theme) }
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .settings)
}
private func installDrawingToolbar() {
guard drawingToolbar.superview == nil else { return }
view.addSubview(drawingToolbar)
drawingToolbar.snp.makeConstraints { make in
make.horizontalEdges.equalToSuperview()
// 工具条背景延伸到屏幕底部;内部控件再自行避开 Home Indicator。
make.bottom.equalToSuperview()
}
drawingToolbar.toolChangedHandler = { [weak self] tool in self?.applyDrawingTool(tool) }
drawingToolbar.colorChangedHandler = { [weak self] color in
guard let self else { return }
self.currentDrawingColor = color
self.applyDrawingTool(self.currentDrawingTool, color: color)
}
drawingToolbar.lineWidthChangedHandler = { [weak self] width in
guard let self else { return }
self.currentDrawingLineWidth = width
if let tool = self.currentDrawingTool {
self.visibleDrawingPages.forEach { $0.setDrawingTool(tool, lineWidth: width) }
}
}
drawingToolbar.undoHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.undoDrawing() } }
drawingToolbar.redoHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.redoDrawing() } }
drawingToolbar.clearHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.clearDrawing() } }
drawingToolbar.layersHandler = { [weak self] in self?.showDrawingLayers() }
drawingToolbar.doneHandler = { [weak self] in self?.setDrawingMode(false) }
}
private var visibleDrawingPages: [RDPDFReaderPageView] {
readerView.visiblePageContentViews().compactMap { $0 as? RDPDFReaderPageView }
}
private func configureVisibleDrawingPages() {
visibleDrawingPages.forEach { page in
page.isDrawingSessionActive = isDrawingMode
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
}
}
private func applyDrawingTool(_ tool: RDPDFReaderDrawingTool?, color: UIColor? = nil) {
currentDrawingTool = tool
if let color { currentDrawingColor = color }
updateDrawingInteractionState()
guard let tool else { return }
visibleDrawingPages.forEach { $0.setDrawingTool(tool, color: color, lineWidth: currentDrawingLineWidth) }
}
/// 未选择画笔工具时恢复阅读器自身的滚动。手机横屏单页的连续竖滑
/// 由外层 collectionView 承担,不能在画笔面板打开期间一概禁用。
private func updateDrawingInteractionState() {
readerView.setPagingEnabled(!isDrawingMode || currentDrawingTool == nil)
readerView.setDrawingToolActive(isDrawingMode && currentDrawingTool != nil)
configureVisibleDrawingPages()
}
private func showDrawingLayers() {
let page = activeDrawingPage ?? visibleDrawingPages.first
guard let page else { return }
let panel = RDPDFReaderDrawingLayersView()
panel.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
panel.addHandler = { [weak self, weak page, weak panel] in
page?.addDrawingLayer()
guard let page else { return }
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
self?.activeDrawingPage = page
}
panel.selectHandler = { [weak page, weak panel] id in
page?.selectDrawingLayer(id: id)
guard let page else { return }
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
}
panel.visibilityHandler = { [weak page, weak panel] id, visible in
page?.setDrawingLayerVisibility(id: id, isVisible: visible)
guard let page else { return }
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
}
panel.deleteHandler = { [weak page, weak panel] id in
page?.deleteDrawingLayer(id: id)
guard let page else { return }
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
}
panel.show(in: view, above: drawingToolbar)
}
/// 一笔画只能属于起笔时的实际 PDF 页。手指越过书脊后,画布会把点钳制在该页边缘,
/// 绝不向相邻页续写,避免横屏双页中出现一条跨页笔迹。
private func routeDrawingStroke(
from source: RDPDFReaderPageView,
point: CGPoint,
phase: RDPDFReaderDrawingStrokePhase
) {
guard isDrawingMode else { return }
// `RDPDFReaderDrawingCanvasView` 已对 source 页外的点执行 clamp;此处明确不做跨页转发。
_ = (source, point)
if phase == .ended || phase == .cancelled { continuedDrawingPage = nil }
}
/// 手机上横屏高度有限,统一使用连续上下滚动;iPad 与手机竖屏保留用户选择。
private var isPhoneLandscape: Bool {
if let phoneLandscapeTransitionOverride { return phoneLandscapeTransitionOverride }
return traitCollection.userInterfaceIdiom == .phone && view.bounds.width > view.bounds.height
}
/// 手机横屏以“纸张浮在浅灰阅读台上”的方式呈现,便于明确区分 PDF 实际页面与留白。
private var pageSurroundingColor: UIColor {
isPhoneLandscape ? UIColor(white: 0.93, alpha: 1) : currentTheme.toolBackgroundColor
}
private func applyDisplayTypeForCurrentInterface() {
let effectiveType: RDPDFReaderView.DisplayType = isPhoneLandscape ? .verticalScroll : preferredDisplayType
view.backgroundColor = isPhoneLandscape ? pageSurroundingColor : currentTheme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor
// 先于 displayType 更新:横竖屏同为竖滑时不会走 switch,
// 由该开关自身触发 cell 重建以套用宽度适配与新的 cell 高度。
readerView.verticalScrollWidthFitEnabled = isPhoneLandscape
guard readerView.currentDisplayType != effectiveType else {
refreshLandscapePageAppearance()
return
}
readerView.switchReaderDisplayType(effectiveType)
refreshLandscapePageAppearance()
}
private func refreshLandscapePageAppearance() {
// 横竖屏同为竖滑时 ReaderView 不会重建 cell,须主动更新所有已显示页
// 的适配模式与周边颜色(预加载的相邻页同样带着旧配置)。
DispatchQueue.main.async { [weak self] in
guard let self, self.readerView.currentPage >= 0 else { return }
for view in self.readerView.visiblePageContentViews() {
guard let page = view as? RDPDFReaderPageView, page.tag >= 0 else { continue }
self.configure(page, at: page.tag)
}
// 旋转落定后安全区可能变化,宽度适配的 cell 高度随之更新。
self.readerView.invalidateWidthFitLayoutIfNeeded()
self.readerView.refreshCurrentPageIfNeeded()
}
}
private func showAnnotations() {
guard annotationPersistence != nil else { return }
let panel = RDPDFReaderAnnotationListViewController { [weak self] in self?.annotations ?? [] }
panel.onSelectAnnotation = { [weak self] item in self?.readerView.transitionToPage(pageNum: item.pageIndex, animated: false) }
panel.onDeleteAnnotation = { [weak self] item in self?.delete(item) }
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation, hidesNavigationBar: false)
}
private func apply(_ theme: RDPDFReaderThemeOption) {
currentTheme = theme
view.backgroundColor = theme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor
applyChromeTheme()
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
}
private func applyChromeTheme() {
topToolbar?.apply(backgroundColor: currentTheme.toolBackgroundColor, tintColor: currentTheme.toolControlTextColor, separatorColor: currentTheme.toolLineColor)
bottomToolbar?.apply(backgroundColor: currentTheme.toolBackgroundColor, tintColor: currentTheme.toolControlTextColor, separatorColor: currentTheme.toolLineColor)
}
private func add(_ selection: RDPDFReaderImageTextSelection, page: Int, color: String = RDPDFReaderPageView.defaultHighlightColor, note: String?) {
guard let annotationPersistence else { return }
do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache()
refreshVisiblePage(page)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
private func delete(_ annotation: RDPDFReaderAnnotation) {
do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
public func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {
delegate?.pdfReaderViewController(self, didCopyText: text)
}
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshVisiblePage(item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
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) })
presentAnnotationEditor(editor)
}
private func presentEditor(annotation: RDPDFReaderAnnotation) {
let editor = RDPDFReaderAnnotationEditorViewController(quote: annotation.selectedText?.isEmpty == false ? annotation.selectedText! : "区域标注", initialNote: annotation.note, theme: currentTheme, onSave: { [weak self] note in
var item = annotation
item.note = note
do {
_ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex)
} catch {
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
})
presentAnnotationEditor(editor)
}
/// 与 EPUB 一致:注释编辑器是系统 page sheet,不使用阅读器的目录/设置底部面板。
private func presentAnnotationEditor(_ editor: RDPDFReaderAnnotationEditorViewController) {
let navigationController = UINavigationController(rootViewController: editor)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
}
public protocol RDPDFReaderViewControllerDelegate: AnyObject {
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController)
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int)
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error)
/// 用户通过选区菜单复制文字后回调;宿主可在此做提示或埋点。
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String)
}
public extension RDPDFReaderViewControllerDelegate {
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {}
}