feat: optimize reader caching and document formats
This commit is contained in:
@@ -2,7 +2,7 @@ Pod::Spec.new do |s|
|
||||
s.name = "RDPDFReaderView"
|
||||
s.module_name = "RDPDFReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "Independent UIKit PDF reader interaction engine"
|
||||
s.summary = "UIKit PDF reader supporting host images and direct PDFKit parsing"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://example.invalid/RDPDFReaderView"
|
||||
@@ -11,6 +11,6 @@ Pod::Spec.new do |s|
|
||||
s.license = "MIT"
|
||||
s.source_files = "{Sources,ReaderView}/**/*.swift"
|
||||
s.dependency "SnapKit", "~> 5.7"
|
||||
s.frameworks = "Vision", "CoreImage"
|
||||
s.frameworks = "Vision", "CoreImage", "PDFKit"
|
||||
s.requires_arc = true
|
||||
end
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
|
||||
主工程的下载、解密、缓存、链接、标注和持久化仍由现有 PDF Feature 管理;后续通过适配层逐步迁移,避免把业务问题和手势问题混在一起。
|
||||
|
||||
## PDF 数据来源
|
||||
|
||||
普通 PDF 可以由 SDK 使用 PDFKit 直接解析:
|
||||
|
||||
```swift
|
||||
let reader = try RDPDFReaderViewController(
|
||||
pdfURL: fileURL,
|
||||
bookIdentifier: stableBookID,
|
||||
password: standardPDFPassword,
|
||||
persistence: persistence,
|
||||
annotationPersistence: annotationStore
|
||||
)
|
||||
```
|
||||
|
||||
自定义加密 PDF 继续由宿主解密并提供页面图片,不需要把原始文件交给 SDK:
|
||||
|
||||
```swift
|
||||
let reader = RDPDFReaderViewController(
|
||||
pageProvider: encryptedImageProvider,
|
||||
persistence: persistence,
|
||||
annotationPersistence: annotationStore
|
||||
)
|
||||
```
|
||||
|
||||
两种入口最终都转换为 `RDPDFReaderPageProvider`,共用翻页、缩放、目录、书签、OCR、标注和画笔功能。内置 PDFKit 数据源按需渲染页面、限制最大图片尺寸并使用内存缓存;能够读取的原生 PDF 文字会直接用于选择和标注,图片扫描页则按配置回退到 OCR。
|
||||
|
||||
## 调试入口
|
||||
|
||||
在任意测试宿主中 push `RDPDFReaderDebugViewController()`:
|
||||
|
||||
@@ -45,6 +45,8 @@ extension RDPDFReaderView: UICollectionViewDataSource, UICollectionViewDelegateF
|
||||
|
||||
private func updateCurrentPageAfterScroll(_ scrollView: UIScrollView) {
|
||||
guard currentDisplayType != .pageCurl else { return }
|
||||
// 用户主动滚动后保留其页内阅读位置;不再应用先前跳页等待异步布局的顶部锚点。
|
||||
pendingWidthFitJumpPage = nil
|
||||
if currentDisplayType == .verticalScroll {
|
||||
// 宽度适配下各 cell 高度不同,页码取视口中心命中的 cell。
|
||||
// 顶部/底部回弹时中心点会越出内容范围,钳制回内容内再取 cell。
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import UIKit
|
||||
import PDFKit
|
||||
|
||||
public enum RDPDFKitPageProviderError: LocalizedError {
|
||||
case cannotOpen(URL)
|
||||
case passwordRequired
|
||||
case incorrectPassword
|
||||
case emptyDocument
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .cannotOpen:
|
||||
return "无法打开 PDF 文件"
|
||||
case .passwordRequired:
|
||||
return "PDF 文件需要密码"
|
||||
case .incorrectPassword:
|
||||
return "PDF 密码不正确"
|
||||
case .emptyDocument:
|
||||
return "PDF 文件中没有可阅读的页面"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK 内置的普通 PDF 数据源。自定义加密 PDF 仍可继续实现
|
||||
/// `RDPDFReaderPageProvider`,无需把原始文件交给 SDK。
|
||||
public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding, RDPDFReaderAsyncOutlineProviding {
|
||||
private let document: PDFDocument
|
||||
private let descriptor: RDPDFReaderBookDescriptor
|
||||
private let renderQueue = DispatchQueue(label: "com.readviewsdk.pdfkit.render", qos: .userInitiated)
|
||||
private let pageCache = NSCache<NSNumber, UIImage>()
|
||||
private let thumbnailCache = NSCache<NSString, UIImage>()
|
||||
private let maximumPagePixelDimension: CGFloat
|
||||
private let screenScale: CGFloat
|
||||
private let textRunDiskCache: RDPDFReaderTextRunDiskCache?
|
||||
/// 只在 `renderQueue` 上访问,PDFDocument 与文字选择始终串行。
|
||||
private var textRunsCache: [Int: [RDPDFReaderTextRun]] = [:]
|
||||
/// 只在 `renderQueue` 上读写。目录通常不变,缓存后打开导航面板无需再次遍历 PDFOutline。
|
||||
private var outlineCache: [RDPDFReaderOutlineItem]?
|
||||
|
||||
/// 默认在设备原生分辨率基础上留出约 2 倍捏合放大余量,并封顶 4096:
|
||||
/// 大屏 iPad 保持清晰度,小屏 iPhone 不为超大开本页面付出数十 MB 的位图。
|
||||
public static var defaultMaximumPagePixelDimension: CGFloat {
|
||||
let screenSize = UIScreen.main.bounds.size
|
||||
let screenMaxPixels = max(screenSize.width, screenSize.height) * UIScreen.main.scale
|
||||
return min(4_096, max(2_048, screenMaxPixels * 2))
|
||||
}
|
||||
|
||||
public init(
|
||||
pdfURL: URL,
|
||||
bookIdentifier: String? = nil,
|
||||
title: String? = nil,
|
||||
password: String? = nil,
|
||||
maximumPagePixelDimension: CGFloat = RDPDFKitPageProvider.defaultMaximumPagePixelDimension,
|
||||
cachesTextRunsOnDisk: Bool = true,
|
||||
textRunDiskCacheVersion: Int = 1
|
||||
) throws {
|
||||
guard let document = PDFDocument(url: pdfURL) else {
|
||||
throw RDPDFKitPageProviderError.cannotOpen(pdfURL)
|
||||
}
|
||||
if document.isLocked {
|
||||
guard let password, password.isEmpty == false else {
|
||||
throw RDPDFKitPageProviderError.passwordRequired
|
||||
}
|
||||
guard document.unlock(withPassword: password), document.isLocked == false else {
|
||||
throw RDPDFKitPageProviderError.incorrectPassword
|
||||
}
|
||||
}
|
||||
guard document.pageCount > 0 else { throw RDPDFKitPageProviderError.emptyDocument }
|
||||
|
||||
self.document = document
|
||||
self.maximumPagePixelDimension = max(1_024, maximumPagePixelDimension)
|
||||
screenScale = max(UIScreen.main.scale, 2)
|
||||
let metadataTitle = document.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String
|
||||
let resolvedIdentifier = bookIdentifier ?? Self.defaultBookIdentifier(for: pdfURL)
|
||||
descriptor = RDPDFReaderBookDescriptor(
|
||||
identifier: resolvedIdentifier,
|
||||
title: title ?? metadataTitle?.nonEmptyPDFTitle ?? pdfURL.deletingPathExtension().lastPathComponent,
|
||||
totalPages: document.pageCount
|
||||
)
|
||||
textRunDiskCache = cachesTextRunsOnDisk
|
||||
? RDPDFReaderTextRunDiskCache(
|
||||
bookIdentifier: resolvedIdentifier,
|
||||
cacheVersion: textRunDiskCacheVersion,
|
||||
namespace: "pdfkit-native",
|
||||
profile: "cropbox-normalized-1"
|
||||
)
|
||||
: nil
|
||||
pageCache.countLimit = 6
|
||||
pageCache.totalCostLimit = 96 * 1024 * 1024
|
||||
thumbnailCache.countLimit = 80
|
||||
thumbnailCache.totalCostLimit = 24 * 1024 * 1024
|
||||
}
|
||||
|
||||
public func readerBookDescriptor() -> RDPDFReaderBookDescriptor { descriptor }
|
||||
|
||||
public func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void) {
|
||||
guard index >= 0, index < descriptor.totalPages else {
|
||||
completion(.init(index: index, image: nil, textRuns: nil))
|
||||
return
|
||||
}
|
||||
if let image = pageCache.object(forKey: NSNumber(value: index)) {
|
||||
renderQueue.async { [weak self] in
|
||||
let runs = self?.cachedTextRuns(for: index)
|
||||
DispatchQueue.main.async {
|
||||
completion(.init(index: index, image: image, textRuns: runs?.isEmpty == false ? runs : nil))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let page = self.document.page(at: index) else {
|
||||
DispatchQueue.main.async { completion(.init(index: index, image: nil, textRuns: nil)) }
|
||||
return
|
||||
}
|
||||
let image = autoreleasepool { self.renderPage(page) }
|
||||
if let image {
|
||||
self.pageCache.setObject(image, forKey: NSNumber(value: index), cost: image.rdEstimatedMemoryCost)
|
||||
}
|
||||
let runs = self.cachedTextRuns(for: index, page: page)
|
||||
DispatchQueue.main.async {
|
||||
completion(.init(index: index, image: image, textRuns: runs.isEmpty ? nil : runs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void) {
|
||||
guard index >= 0, index < descriptor.totalPages,
|
||||
targetSize.width > 0, targetSize.height > 0 else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let key = NSString(string: "\(index)-\(Int(targetSize.width.rounded()))x\(Int(targetSize.height.rounded()))")
|
||||
if let cached = thumbnailCache.object(forKey: key) {
|
||||
completion(cached)
|
||||
return
|
||||
}
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let page = self.document.page(at: index) else {
|
||||
DispatchQueue.main.async { completion(nil) }
|
||||
return
|
||||
}
|
||||
let image = autoreleasepool { page.thumbnail(of: targetSize, for: .cropBox) }
|
||||
self.thumbnailCache.setObject(image, forKey: key, cost: image.rdEstimatedMemoryCost)
|
||||
DispatchQueue.main.async { completion(image) }
|
||||
}
|
||||
}
|
||||
|
||||
public func readerOutlineItems() -> [RDPDFReaderOutlineItem] {
|
||||
renderQueue.sync { outlineItemsLocked() }
|
||||
}
|
||||
|
||||
public func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void) {
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let items = self.outlineItemsLocked()
|
||||
DispatchQueue.main.async { completion(items) }
|
||||
}
|
||||
}
|
||||
|
||||
public func removeCachedImages() {
|
||||
pageCache.removeAllObjects()
|
||||
thumbnailCache.removeAllObjects()
|
||||
renderQueue.async { [weak self] in self?.textRunsCache.removeAll(keepingCapacity: false) }
|
||||
}
|
||||
|
||||
/// 与控制器的页面图片缓存窗口保持一致,避免原生文本结果在长 PDF 中无限累积。
|
||||
public func trimTextRuns(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.textRunsCache = self.textRunsCache.filter { abs($0.key - pageIndex) <= safeRadius }
|
||||
}
|
||||
}
|
||||
|
||||
private func renderPage(_ page: PDFPage) -> UIImage? {
|
||||
let bounds = page.bounds(for: .cropBox)
|
||||
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
||||
let requestedMaximum = max(bounds.width, bounds.height) * screenScale
|
||||
let scale = min(screenScale, maximumPagePixelDimension / max(bounds.width, bounds.height))
|
||||
let effectiveScale = requestedMaximum > maximumPagePixelDimension ? max(scale, 0.1) : screenScale
|
||||
let pixelSize = CGSize(
|
||||
width: max(1, (bounds.width * effectiveScale).rounded(.up)),
|
||||
height: max(1, (bounds.height * effectiveScale).rounded(.up))
|
||||
)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
return UIGraphicsImageRenderer(size: pixelSize, format: format).image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: pixelSize))
|
||||
context.cgContext.translateBy(x: 0, y: pixelSize.height)
|
||||
context.cgContext.scaleBy(x: effectiveScale, y: -effectiveScale)
|
||||
context.cgContext.translateBy(x: -bounds.minX, y: -bounds.minY)
|
||||
page.draw(with: .cropBox, to: context.cgContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func cachedTextRuns(for index: Int, page suppliedPage: PDFPage? = nil) -> [RDPDFReaderTextRun] {
|
||||
if let cached = textRunsCache[index] { return cached }
|
||||
if let cached = textRunDiskCache?.loadSynchronously(pageIndex: index) {
|
||||
textRunsCache[index] = cached
|
||||
return cached
|
||||
}
|
||||
guard let page = suppliedPage ?? document.page(at: index) else { return [] }
|
||||
let runs = textRuns(for: page)
|
||||
textRunsCache[index] = runs
|
||||
textRunDiskCache?.save(runs, pageIndex: index)
|
||||
return runs
|
||||
}
|
||||
|
||||
/// 身份签名只取文件名、大小和头部内容。绝对路径在 iOS 容器迁移(应用更新、
|
||||
/// 备份恢复)后会变化,修改时间在重新下载后会变化;它们参与签名会让以
|
||||
/// identifier 为 key 的书签、笔迹和阅读进度整体失效。
|
||||
private static func defaultBookIdentifier(for url: URL) -> String {
|
||||
let fileURL = url.standardizedFileURL
|
||||
let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
|
||||
var signature = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")"
|
||||
if let handle = try? FileHandle(forReadingFrom: fileURL) {
|
||||
let head = handle.readData(ofLength: 64 * 1024)
|
||||
handle.closeFile()
|
||||
if head.isEmpty == false {
|
||||
signature += "|\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: head))"
|
||||
}
|
||||
}
|
||||
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: signature))"
|
||||
}
|
||||
|
||||
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
|
||||
if let outlineCache { return outlineCache }
|
||||
guard let root = document.outlineRoot else {
|
||||
let items = fallbackOutline()
|
||||
outlineCache = items
|
||||
return items
|
||||
}
|
||||
var items: [RDPDFReaderOutlineItem] = []
|
||||
func appendChildren(of outline: PDFOutline, level: Int) {
|
||||
for childIndex in 0..<outline.numberOfChildren {
|
||||
guard let child = outline.child(at: childIndex) else { continue }
|
||||
if let page = child.destination?.page {
|
||||
let pageIndex = document.index(for: page)
|
||||
if pageIndex != NSNotFound {
|
||||
items.append(.init(
|
||||
title: child.label?.nonEmptyPDFTitle ?? "第 \(pageIndex + 1) 页",
|
||||
pageIndex: pageIndex,
|
||||
level: level
|
||||
))
|
||||
}
|
||||
}
|
||||
appendChildren(of: child, level: level + 1)
|
||||
}
|
||||
}
|
||||
appendChildren(of: root, level: 0)
|
||||
let resolved = items.isEmpty ? fallbackOutline() : items
|
||||
outlineCache = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
private func textRuns(for page: PDFPage) -> [RDPDFReaderTextRun] {
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
guard pageBounds.width > 0, pageBounds.height > 0,
|
||||
let selection = page.selection(for: pageBounds) else { return [] }
|
||||
return selection.selectionsByLine().enumerated().compactMap { order, line in
|
||||
guard let text = line.string?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false else {
|
||||
return nil
|
||||
}
|
||||
let rect = line.bounds(for: page).intersection(pageBounds)
|
||||
guard rect.isNull == false, rect.isEmpty == false else { return nil }
|
||||
let normalized = CGRect(
|
||||
x: (rect.minX - pageBounds.minX) / pageBounds.width,
|
||||
y: (pageBounds.maxY - rect.maxY) / pageBounds.height,
|
||||
width: rect.width / pageBounds.width,
|
||||
height: rect.height / pageBounds.height
|
||||
)
|
||||
return RDPDFReaderTextRun(text: text, normalizedRect: normalized, readingOrder: order)
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackOutline() -> [RDPDFReaderOutlineItem] {
|
||||
(0..<descriptor.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
var rdEstimatedMemoryCost: Int {
|
||||
guard let cgImage else { return Int(size.width * size.height * scale * scale * 4) }
|
||||
return cgImage.bytesPerRow * cgImage.height
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmptyPDFTitle: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public protocol RDPDFReaderPageInteractable: AnyObject {
|
||||
var readerHasActiveTextSelection: Bool { get }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool)
|
||||
func readerClearTextSelection()
|
||||
/// 显式跳页前提交正在进行的笔迹,避免页面视图被替换时丢失最后一笔。
|
||||
func readerFinishActiveDrawingStroke()
|
||||
func readerBeginExternalPinch(at point: CGPoint)
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint)
|
||||
func readerEndExternalPinch()
|
||||
@@ -39,6 +41,7 @@ public extension RDPDFReaderPageInteractable {
|
||||
var readerHasActiveTextSelection: Bool { false }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) {}
|
||||
func readerClearTextSelection() {}
|
||||
func readerFinishActiveDrawingStroke() {}
|
||||
func readerBeginExternalPinch(at point: CGPoint) {}
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {}
|
||||
func readerEndExternalPinch() {}
|
||||
@@ -137,6 +140,9 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
/// UICollectionViewFlowLayout 不会因为仅修改 itemSize 就立即丢弃旋转前的布局缓存。
|
||||
/// 记录上一次实际应用的尺寸,横竖屏变化时主动失效,避免横屏首帧仍排入两个竖屏 cell。
|
||||
private var appliedCollectionItemSize = CGSize.zero
|
||||
/// 手机横屏宽度适配下,远跳的目标页可能先按占位高度布局,待真实页面数据
|
||||
/// 到达后需要重新将目标页锚定到顶部。
|
||||
var pendingWidthFitJumpPage: Int?
|
||||
private var reusableTypes: [String: UIView.Type] = [:]
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@@ -263,8 +269,19 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
guard isPagingEnabled, !isCurrentPageZoomed, !isCurrentPageTextSelectionActive, let page = clamped(pageNum) else { return }
|
||||
guard let page = clamped(pageNum) else { return }
|
||||
// 目录、缩略图、书签和宿主 API 是显式导航:提交笔迹并清除选区后允许跳页。
|
||||
// `isPagingEnabled` 仅限制用户的翻页手势,不能阻断这些入口。
|
||||
visiblePageContentViews().compactMap { $0 as? RDPDFReaderPageInteractable }.forEach {
|
||||
$0.readerFinishActiveDrawingStroke()
|
||||
$0.readerClearTextSelection()
|
||||
}
|
||||
isCurrentPageTextSelectionActive = false
|
||||
// 目录、缩略图、书签及宿主 API 均通过此入口进行显式跳页。放大层会接管
|
||||
// 当前页视图,必须先归还该视图,才能让目标页正常显示。
|
||||
if isCurrentPageZoomed { dismissZoomOverlay() }
|
||||
let displayedPage = usesLandscapeSpread ? spreadResolver.pair(for: page, totalPages: pageCount()).left : page
|
||||
if usesWidthFitVerticalScroll { pendingWidthFitJumpPage = displayedPage }
|
||||
if currentDisplayType == .pageCurl {
|
||||
transitionCurl(to: displayedPage, animated: animated)
|
||||
} else {
|
||||
@@ -295,12 +312,35 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
/// 宽度适配竖滑下页面异步加载完成后,cell 高度会从"一屏高"变为真实纸张高度,
|
||||
/// 宿主拿到页面数据时调用此方法刷新布局。
|
||||
public func invalidateWidthFitLayoutIfNeeded() {
|
||||
public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
|
||||
guard usesWidthFitVerticalScroll else { return }
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
// 仅修正由目录、缩略图、书签等显式跳页建立的锚点。普通阅读中页面
|
||||
// 异步加载不应把用户正在浏览的页内位置强制拉回顶部。
|
||||
guard let updatedPage, updatedPage == pendingWidthFitJumpPage else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.usesWidthFitVerticalScroll,
|
||||
self.currentPage == updatedPage,
|
||||
self.pendingWidthFitJumpPage == updatedPage else { return }
|
||||
// 页面数据到达时用户可能已经按住屏幕拖动;此时手指拥有位置,放弃锚点回写。
|
||||
guard self.collectionView.isTracking == false else {
|
||||
self.pendingWidthFitJumpPage = nil
|
||||
return
|
||||
}
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
|
||||
animated: false
|
||||
)
|
||||
self.pendingWidthFitJumpPage = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
|
||||
|
||||
/// 画笔/橡皮被选中时调用。放大层随之把单指手势让给页面画布,只保留双指拖动与捏合。
|
||||
public func setDrawingToolActive(_ active: Bool) { zoomOverlayView.isDrawingToolActive = active }
|
||||
public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } }
|
||||
public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() }
|
||||
|
||||
@@ -528,6 +568,11 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
var onZoomStateChanged: ((Bool) -> Void)?
|
||||
private(set) var isPresented = false
|
||||
|
||||
/// 画笔/橡皮被选中时,放大层把单指让给页面内的画布,仅保留双指拖动与捏合。
|
||||
var isDrawingToolActive = false {
|
||||
didSet { scrollView.panGestureRecognizer.minimumNumberOfTouches = isDrawingToolActive ? 2 : 1 }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
@@ -536,8 +581,13 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
scrollView.minimumZoomScale = 0.65
|
||||
scrollView.maximumZoomScale = 3
|
||||
scrollView.bouncesZoom = true
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
// 画笔选中时笔迹必须立即送达画布,不能等滚动判定;未选中时画布不接收触摸,无副作用。
|
||||
// 保留 canCancelContentTouches 默认值:双指拖动仍可从画布手中接管触摸,
|
||||
// 画布在收到第二根手指或触摸取消时会自行丢弃未完成笔迹。
|
||||
scrollView.delaysContentTouches = false
|
||||
// Overlay 显示后的后续捏合由 UIScrollView 自己接收,也必须遵循缩小回弹流程。
|
||||
scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:)))
|
||||
addSubview(scrollView)
|
||||
@@ -558,6 +608,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
|
||||
func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) {
|
||||
self.backgroundColor = backgroundColor
|
||||
// Overlay 仍隐藏时完成所有换父视图和居中布局,避免把中间态提交到屏幕。
|
||||
layoutIfNeeded()
|
||||
restoreLeasedPages()
|
||||
let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height))
|
||||
canvasView.frame = CGRect(origin: .zero, size: safeSize)
|
||||
@@ -570,12 +622,14 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
$0.readerSetInternalGesturesEnabled(false)
|
||||
}
|
||||
scrollView.setZoomScale(1, animated: false)
|
||||
scrollView.contentOffset = .zero
|
||||
updateInsets()
|
||||
// UIScrollView 在带 contentInset 的静止位置不是 .zero,而是负 inset。
|
||||
// 先写 .zero 再等待 UIKit 校正会产生一帧书页贴顶的闪动。
|
||||
scrollView.contentOffset = CGPoint(x: -scrollView.contentInset.left, y: -scrollView.contentInset.top)
|
||||
// 容器在 present 后已进入放大态;即便用户第一步就是缩小,也要保证结束时能发出 false。
|
||||
reportedZoomed = true
|
||||
isPresented = true
|
||||
isHidden = false
|
||||
updateInsets()
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
@@ -677,6 +731,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
page.autoresizingMask = []
|
||||
canvasView.addSubview(page)
|
||||
page.frame = frame
|
||||
page.setNeedsLayout()
|
||||
page.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func restoreLeasedPages() {
|
||||
@@ -736,6 +792,8 @@ final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pageViews: [UIView] { [leftPage, rightPage].compactMap { $0 } }
|
||||
|
||||
var readerContentTapHandler: ((CGPoint) -> Void)?
|
||||
var readerZoomStateChangedHandler: ((Bool) -> Void)?
|
||||
var readerSelectionStateChangedHandler: ((Bool) -> Void)?
|
||||
@@ -744,6 +802,7 @@ final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
|
||||
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) { pages.forEach { $0.readerSetInternalGesturesEnabled(enabled) } }
|
||||
func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } }
|
||||
func readerFinishActiveDrawingStroke() { pages.forEach { $0.readerFinishActiveDrawingStroke() } }
|
||||
func readerBeginExternalPinch(at point: CGPoint) { activePage(at: point)?.readerBeginExternalPinch(at: point) }
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { activePage(at: point)?.readerUpdateExternalPinch(scale: scale, at: point) }
|
||||
func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } }
|
||||
|
||||
@@ -238,7 +238,8 @@ public struct RDPDFReaderHighlight: Equatable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主程序实现此协议,把“已经解析好的页面”提供给 SDK。
|
||||
/// 主程序实现此协议,把“已经解析好的页面”提供给 SDK。页面和缩略图回调可在任意
|
||||
/// 队列触发;SDK 会在接收结果后自行切回主线程更新界面。
|
||||
public protocol RDPDFReaderPageProvider: AnyObject {
|
||||
func readerBookDescriptor() -> RDPDFReaderBookDescriptor
|
||||
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void)
|
||||
@@ -267,6 +268,12 @@ public protocol RDPDFReaderOutlineProviding: AnyObject {
|
||||
func readerOutlineItems() -> [RDPDFReaderOutlineItem]
|
||||
}
|
||||
|
||||
/// 可选的异步目录提供协议。适用于目录解析可能耗时的文件格式;SDK 会在加载完成后
|
||||
/// 再展示导航面板,避免阻塞主线程。未实现时继续兼容 `RDPDFReaderOutlineProviding`。
|
||||
public protocol RDPDFReaderAsyncOutlineProviding: AnyObject {
|
||||
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void)
|
||||
}
|
||||
|
||||
/// 媒体、购买、外链等业务行为由宿主处理,SDK 不引用路由或播放器实现。
|
||||
public enum RDPDFReaderHostAction: Equatable {
|
||||
case back
|
||||
|
||||
@@ -8,21 +8,36 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
public let tool: RDPDFReaderDrawingTool
|
||||
/// 笔迹所属图层。旧版文件没有该字段时会归入默认图层。
|
||||
public let layerID: UUID?
|
||||
/// 创建笔迹时的实际书页尺寸。渲染时会从该尺寸映射到当前书页尺寸,
|
||||
/// 因而横竖屏切换只改变显示比例,不改变笔迹相对 PDF 页的位置和粗细。
|
||||
/// 旧版数据没有该字段时继续按原始 UIKit 点坐标显示。
|
||||
public let referencePageSize: CGSize?
|
||||
public var points: [CGPoint]
|
||||
public let createdAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), page: Int, color: String, lineWidth: CGFloat, tool: RDPDFReaderDrawingTool, layerID: UUID? = nil, points: [CGPoint], createdAt: Date = Date()) {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
page: Int,
|
||||
color: String,
|
||||
lineWidth: CGFloat,
|
||||
tool: RDPDFReaderDrawingTool,
|
||||
layerID: UUID? = nil,
|
||||
referencePageSize: CGSize? = nil,
|
||||
points: [CGPoint],
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.page = page
|
||||
self.color = color
|
||||
self.lineWidth = lineWidth
|
||||
self.tool = tool
|
||||
self.layerID = layerID
|
||||
self.referencePageSize = referencePageSize
|
||||
self.points = points
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case id, page, color, lineWidth, tool, layerID, points, createdAt }
|
||||
private enum CodingKeys: String, CodingKey { case id, page, color, lineWidth, tool, layerID, referencePageSize, points, createdAt }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
@@ -32,6 +47,14 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
lineWidth = try container.decode(CGFloat.self, forKey: .lineWidth)
|
||||
tool = try container.decode(RDPDFReaderDrawingTool.self, forKey: .tool)
|
||||
layerID = try container.decodeIfPresent(UUID.self, forKey: .layerID)
|
||||
if let values = try container.decodeIfPresent([CGFloat].self, forKey: .referencePageSize),
|
||||
values.count >= 2,
|
||||
values[0] > 0,
|
||||
values[1] > 0 {
|
||||
referencePageSize = CGSize(width: values[0], height: values[1])
|
||||
} else {
|
||||
referencePageSize = nil
|
||||
}
|
||||
createdAt = Date(timeIntervalSince1970: try container.decode(TimeInterval.self, forKey: .createdAt))
|
||||
let rawPoints = try container.decode([[CGFloat]].self, forKey: .points)
|
||||
points = rawPoints.compactMap { values in values.count >= 2 ? CGPoint(x: values[0], y: values[1]) : nil }
|
||||
@@ -45,6 +68,9 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
try container.encode(lineWidth, forKey: .lineWidth)
|
||||
try container.encode(tool, forKey: .tool)
|
||||
try container.encodeIfPresent(layerID, forKey: .layerID)
|
||||
if let referencePageSize {
|
||||
try container.encode([referencePageSize.width, referencePageSize.height], forKey: .referencePageSize)
|
||||
}
|
||||
try container.encode(points.map { [$0.x, $0.y] }, forKey: .points)
|
||||
try container.encode(createdAt.timeIntervalSince1970, forKey: .createdAt)
|
||||
}
|
||||
@@ -115,6 +141,8 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
super.init(frame: frame)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
// 书页尺寸变化时重新按 referencePageSize 绘制,避免复用旧位图造成失真或模糊。
|
||||
contentMode = .redraw
|
||||
contentScaleFactor = UIScreen.main.scale
|
||||
isMultipleTouchEnabled = true
|
||||
}
|
||||
@@ -128,7 +156,17 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
|
||||
layers = [defaultLayer]
|
||||
paths = paths.map { path in
|
||||
RDPDFReaderDrawingPath(id: path.id, page: path.page, color: path.color, lineWidth: path.lineWidth, tool: path.tool, layerID: defaultLayer.id, points: path.points, createdAt: path.createdAt)
|
||||
RDPDFReaderDrawingPath(
|
||||
id: path.id,
|
||||
page: path.page,
|
||||
color: path.color,
|
||||
lineWidth: path.lineWidth,
|
||||
tool: path.tool,
|
||||
layerID: defaultLayer.id,
|
||||
referencePageSize: path.referencePageSize,
|
||||
points: path.points,
|
||||
createdAt: path.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id
|
||||
@@ -142,6 +180,12 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
|
||||
|
||||
public func currentPaths() -> [RDPDFReaderDrawingPath] { paths }
|
||||
/// 显式跳页或关闭画笔时提交当前已采集的笔迹,避免 UIView 被移除导致最后一笔丢失。
|
||||
public func finishCurrentStroke() {
|
||||
guard currentPath != nil else { return }
|
||||
commitCurrentStroke()
|
||||
activeDrawingTouch = nil
|
||||
}
|
||||
public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
|
||||
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
|
||||
public func selectedDrawingLayerID() -> UUID? { activeLayerID }
|
||||
@@ -299,23 +343,25 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
|
||||
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
|
||||
guard !path.points.isEmpty else { return }
|
||||
let renderedPoints = path.points.map { renderedPoint($0, for: path) }
|
||||
let renderedLineWidth = path.lineWidth * renderedScale(for: path)
|
||||
context.setBlendMode(path.tool == .eraser ? .clear : .normal)
|
||||
context.setStrokeColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setFillColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setLineWidth(path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth)
|
||||
context.setLineWidth(path.tool == .eraser ? eraserLineWidth(for: path, renderedLineWidth: renderedLineWidth) : renderedLineWidth)
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
context.setAlpha(path.tool == .highlighter ? 0.3 : 1)
|
||||
if path.points.count == 1 {
|
||||
let radius = (path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth) / 2
|
||||
let point = path.points[0]
|
||||
if renderedPoints.count == 1 {
|
||||
let radius = (path.tool == .eraser ? eraserLineWidth(for: path, renderedLineWidth: renderedLineWidth) : renderedLineWidth) / 2
|
||||
let point = renderedPoints[0]
|
||||
context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
return
|
||||
}
|
||||
context.beginPath()
|
||||
addSmoothedCurve(for: path.points, to: context)
|
||||
addSmoothedCurve(for: renderedPoints, to: context)
|
||||
context.strokePath()
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
@@ -339,13 +385,35 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
lineWidth: tool == .highlighter ? 8 : lineWidth,
|
||||
tool: tool,
|
||||
layerID: activeLayerID,
|
||||
referencePageSize: bounds.size,
|
||||
points: [clampedPoint(point)]
|
||||
)
|
||||
}
|
||||
|
||||
private func eraserLineWidth(for path: RDPDFReaderDrawingPath) -> CGFloat {
|
||||
// SheetMusic 的橡皮与画笔共享大小滑杆;在 UIKit 点坐标系中给出可感知的对应直径。
|
||||
max(path.lineWidth * 2, 12)
|
||||
private func renderedPoint(_ point: CGPoint, for path: RDPDFReaderDrawingPath) -> CGPoint {
|
||||
guard let referenceSize = path.referencePageSize,
|
||||
referenceSize.width > 0,
|
||||
referenceSize.height > 0,
|
||||
bounds.width > 0,
|
||||
bounds.height > 0 else { return point }
|
||||
return CGPoint(
|
||||
x: point.x * bounds.width / referenceSize.width,
|
||||
y: point.y * bounds.height / referenceSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private func renderedScale(for path: RDPDFReaderDrawingPath) -> CGFloat {
|
||||
guard let referenceSize = path.referencePageSize,
|
||||
referenceSize.width > 0,
|
||||
referenceSize.height > 0,
|
||||
bounds.width > 0,
|
||||
bounds.height > 0 else { return 1 }
|
||||
return min(bounds.width / referenceSize.width, bounds.height / referenceSize.height)
|
||||
}
|
||||
|
||||
private func eraserLineWidth(for path: RDPDFReaderDrawingPath, renderedLineWidth: CGFloat) -> CGFloat {
|
||||
// 橡皮最小直径也随实际书页缩放,避免横竖屏切换后擦除范围与笔迹比例脱节。
|
||||
max(renderedLineWidth * 2, 12 * renderedScale(for: path))
|
||||
}
|
||||
|
||||
/// 触点使用 Catmull–Rom 到三次贝塞尔的转换。这样保留原始笔迹数据和撤销语义,
|
||||
|
||||
@@ -346,10 +346,13 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
|
||||
}
|
||||
|
||||
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// UIKit 会就“命中本视图的所有手势”(含外层翻页滚动、阅读器捏合)调用此方法;
|
||||
// 这里只允许约束本层自己的选择手势,其余一律交还默认判定,否则画笔会话
|
||||
// 关闭选区(isSelectionEnabled = false)时会连带否决外层的拖动与缩放。
|
||||
let ownsGesture = gestureRecognizer === longPressGestureRecognizer
|
||||
|| gestureRecognizer === selectionHandlePanGestureRecognizer
|
||||
guard ownsGesture else { return super.gestureRecognizerShouldBegin(gestureRecognizer) }
|
||||
guard isSelectionEnabled, bounds.width > 0, bounds.height > 0 else { return false }
|
||||
if gestureRecognizer === longPressGestureRecognizer {
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === selectionHandlePanGestureRecognizer {
|
||||
guard let selection = selectedSelection, selection.source != .region else { return false }
|
||||
return selectionHandle(at: gestureRecognizer.location(in: self)) != nil
|
||||
|
||||
@@ -20,6 +20,11 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
label: "com.readoor.pdf-reader.image-text-recognizer",
|
||||
qos: .userInitiated
|
||||
)
|
||||
private let requestLock = NSLock()
|
||||
private var pendingRequests: [UUID: DispatchWorkItem] = [:]
|
||||
/// 已进入 `perform` 的请求。`DispatchWorkItem.cancel()` 中断不了它们,
|
||||
/// 必须对 VNRequest 本身调用 `cancel()`。
|
||||
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
|
||||
|
||||
public init(
|
||||
recognitionLevel: VNRequestTextRecognitionLevel = .accurate,
|
||||
@@ -32,10 +37,12 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
}
|
||||
|
||||
/// 异步识别页面图片中的文字。结果的矩形以图片左上角为原点,范围为 0...1。
|
||||
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) {
|
||||
@discardableResult
|
||||
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) -> UUID {
|
||||
let requestID = UUID()
|
||||
guard let cgImage = Self.makeCGImage(from: image) else {
|
||||
deliver([], to: completion)
|
||||
return
|
||||
return requestID
|
||||
}
|
||||
|
||||
let configuration = Configuration(
|
||||
@@ -45,22 +52,50 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
)
|
||||
let orientation = CGImagePropertyOrientation(orientation: image.imageOrientation)
|
||||
|
||||
processingQueue.async {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let observations = request.results as? [VNRecognizedTextObservation] ?? []
|
||||
let runs = Self.makeTextRuns(from: observations)
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
self.deliver(runs, to: completion)
|
||||
}
|
||||
request.recognitionLevel = configuration.recognitionLevel
|
||||
request.recognitionLanguages = configuration.recognitionLanguages
|
||||
request.usesLanguageCorrection = configuration.usesLanguageCorrection
|
||||
|
||||
guard self.registerActiveVisionRequest(request, for: requestID) else { return }
|
||||
var performFailed = false
|
||||
do {
|
||||
try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
|
||||
} catch {
|
||||
performFailed = true
|
||||
}
|
||||
self.unregisterActiveVisionRequest(requestID)
|
||||
if performFailed {
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
self.deliver([], to: completion)
|
||||
}
|
||||
}
|
||||
requestLock.lock()
|
||||
pendingRequests[requestID] = workItem
|
||||
requestLock.unlock()
|
||||
processingQueue.async(execute: workItem)
|
||||
return requestID
|
||||
}
|
||||
|
||||
/// 丢弃尚未开始的识别请求,并忽略正在执行请求的完成结果。
|
||||
/// 用于快速翻页后优先让当前停留页进入串行 OCR 队列。
|
||||
public func cancelAllRequests() {
|
||||
requestLock.lock()
|
||||
let queued = pendingRequests.values
|
||||
let inFlight = activeVisionRequests.values
|
||||
pendingRequests.removeAll()
|
||||
activeVisionRequests.removeAll()
|
||||
requestLock.unlock()
|
||||
queued.forEach { $0.cancel() }
|
||||
// 让正在执行的识别尽快返回,当前停留页不必等整页识别跑完才能入队。
|
||||
inFlight.forEach { $0.cancel() }
|
||||
}
|
||||
|
||||
/// Swift Concurrency 版本,与回调版本使用相同的识别和主线程回调语义。
|
||||
@@ -171,6 +206,30 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
completion(runs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有仍处于活跃状态的请求才能进入执行阶段并登记可取消的 VNRequest。
|
||||
private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard pendingRequests[requestID]?.isCancelled == false else { return false }
|
||||
activeVisionRequests[requestID] = request
|
||||
return true
|
||||
}
|
||||
|
||||
private func unregisterActiveVisionRequest(_ requestID: UUID) {
|
||||
requestLock.lock()
|
||||
activeVisionRequests.removeValue(forKey: requestID)
|
||||
requestLock.unlock()
|
||||
}
|
||||
|
||||
/// 仅活跃请求能取得完成权;被取消或已替代的请求结果必须丢弃。
|
||||
@discardableResult
|
||||
private func removeRequest(_ requestID: UUID) -> Bool {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard let request = pendingRequests.removeValue(forKey: requestID), !request.isCancelled else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGImagePropertyOrientation {
|
||||
@@ -197,3 +256,87 @@ private extension CGImagePropertyOrientation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面文本结果的可清除磁盘缓存。OCR 与 PDFKit 原生文本使用不同命名空间,避免混用。
|
||||
final class RDPDFReaderTextRunDiskCache {
|
||||
private struct Document: Codable {
|
||||
let version: Int
|
||||
let runs: [RDPDFReaderTextRun]
|
||||
}
|
||||
|
||||
private static let documentVersion = 1
|
||||
private let rootURL: URL
|
||||
private let queue = DispatchQueue(label: "com.readoor.pdf-reader.ocr-disk-cache", qos: .utility)
|
||||
|
||||
init(bookIdentifier: String, cacheVersion: Int, namespace: String, profile: String = "") {
|
||||
// 目录分两级:书籍散列在外、命名空间+版本+配置在内。这样 version/profile
|
||||
// 变更后能定位并删除同一本书同一命名空间下不再使用的旧目录。
|
||||
let variantName = "\(namespace)-v\(cacheVersion)-\(Self.stableIdentifier(for: profile))"
|
||||
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let bookRootURL = cachesURL
|
||||
.appendingPathComponent("RDPDFReaderView", isDirectory: true)
|
||||
.appendingPathComponent("TextRuns", isDirectory: true)
|
||||
.appendingPathComponent(Self.stableIdentifier(for: bookIdentifier), isDirectory: true)
|
||||
rootURL = bookRootURL.appendingPathComponent(variantName, isDirectory: true)
|
||||
queue.async {
|
||||
let entries = (try? FileManager.default.contentsOfDirectory(
|
||||
at: bookRootURL,
|
||||
includingPropertiesForKeys: nil
|
||||
)) ?? []
|
||||
for entry in entries
|
||||
where entry.lastPathComponent.hasPrefix("\(namespace)-") && entry.lastPathComponent != variantName {
|
||||
try? FileManager.default.removeItem(at: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func load(pageIndex: Int, completion: @escaping ([RDPDFReaderTextRun]?) -> Void) {
|
||||
queue.async {
|
||||
let runs = self.loadLocked(pageIndex: pageIndex)
|
||||
DispatchQueue.main.async { completion(runs) }
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFKit 的渲染队列在后台调用,避免为了读磁盘缓存再切换到主线程。
|
||||
func loadSynchronously(pageIndex: Int) -> [RDPDFReaderTextRun]? {
|
||||
queue.sync { loadLocked(pageIndex: pageIndex) }
|
||||
}
|
||||
|
||||
func save(_ runs: [RDPDFReaderTextRun], pageIndex: Int) {
|
||||
let url = fileURL(for: pageIndex)
|
||||
queue.async {
|
||||
guard let data = try? JSONEncoder().encode(Document(version: Self.documentVersion, runs: runs)) else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: self.rootURL, withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
// 文本缓存不可用时仍可重新提取/识别,不能影响阅读流程。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fileURL(for pageIndex: Int) -> URL {
|
||||
rootURL.appendingPathComponent("\(max(0, pageIndex)).json")
|
||||
}
|
||||
|
||||
static func stableIdentifier(for value: String) -> String {
|
||||
stableIdentifier(for: Array(value.utf8))
|
||||
}
|
||||
|
||||
static func stableIdentifier<Bytes: Sequence>(for bytes: Bytes) -> String where Bytes.Element == UInt8 {
|
||||
var hash: UInt64 = 14_695_981_039_346_656_037
|
||||
for byte in bytes {
|
||||
hash ^= UInt64(byte)
|
||||
hash &*= 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16)
|
||||
}
|
||||
|
||||
private func loadLocked(pageIndex: Int) -> [RDPDFReaderTextRun]? {
|
||||
let url = fileURL(for: pageIndex)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let document = try? JSONDecoder().decode(Document.self, from: data),
|
||||
document.version == Self.documentVersion else { return nil }
|
||||
return document.runs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
|
||||
public func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) }
|
||||
public func readerClearTextSelection() { textLayer.clearSelection() }
|
||||
public func readerFinishActiveDrawingStroke() { drawingCanvas.finishCurrentStroke() }
|
||||
public func readerBeginExternalPinch(at point: CGPoint) { zoomView.beginExternalPinch(at: convert(point, to: zoomView)) }
|
||||
public func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {
|
||||
zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView))
|
||||
@@ -135,6 +136,8 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
) {
|
||||
textLayer.pageIndex = pageIndex
|
||||
textLayer.textSource = textSource
|
||||
// 无文字来源时长按空白进入区域框选;有文字时保持与 EPUB 一致只响应文本。
|
||||
textLayer.allowsRegionSelection = textSource == .region
|
||||
textLayer.textRuns = textRuns
|
||||
textLayer.annotations = annotations
|
||||
updateAccessibilityViewport()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 成品 PDF 阅读控制器。宿主提供已解析的页面和可选存储;阅读交互、OCR、标注及面板
|
||||
/// 由 SDK 统一编排,因此 SDK 不需要接触 PDFKit、数据库或应用路由。
|
||||
/// 成品 PDF 阅读控制器。自定义加密文件由宿主提供页面;普通 PDF 可由 SDK 使用
|
||||
/// PDFKit 直接解析。两种来源共用阅读交互、OCR、标注及面板。
|
||||
public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate {
|
||||
|
||||
public struct Configuration {
|
||||
@@ -13,9 +13,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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 时,控制器保留当前页前后页面描述的半径,避免长 PDF 阅读时
|
||||
/// 持续持有已离开的页面图片。宿主 Provider 的原有缓存行为保持不变。
|
||||
public var pageDescriptorCacheRadius = 3
|
||||
/// 内存中保留当前页前后的 OCR 结果数量;默认沿用页面描述缓存半径。
|
||||
public var ocrMemoryCacheRadius: Int?
|
||||
/// 宿主或内置 Provider 提供页面后,可在此进行反色或其它主题渲染。
|
||||
public var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
|
||||
|
||||
public init() {}
|
||||
@@ -29,14 +42,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
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] = [:]
|
||||
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
|
||||
private var recognizingPages = Set<Int>()
|
||||
/// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。
|
||||
private var ocrRequestTokens: [Int: UUID] = [:]
|
||||
private var bookmarks = Set<Int>()
|
||||
private weak var topToolbar: RDPDFReaderKitTopToolView?
|
||||
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
|
||||
private var navigationLoadingIndicator: UIActivityIndicatorView?
|
||||
private let drawingToolbar = RDPDFReaderDrawingToolbar()
|
||||
private var isDrawingMode = false
|
||||
/// `nil` 时处于画笔面板内的浏览状态,可单指拖动画面。
|
||||
@@ -66,10 +83,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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() {
|
||||
@@ -100,6 +152,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
|
||||
}
|
||||
|
||||
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) }
|
||||
(pageProvider as? RDPDFKitPageProvider)?.removeCachedImages()
|
||||
visibleIndexes.sorted().forEach { startOCRIfNeeded($0) }
|
||||
}
|
||||
|
||||
public func switchDisplayType(_ type: RDPDFReaderView.DisplayType) {
|
||||
preferredDisplayType = type
|
||||
applyDisplayTypeForCurrentInterface()
|
||||
@@ -170,6 +234,21 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
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)
|
||||
(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))
|
||||
@@ -177,6 +256,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
delegate?.pdfReaderViewController(self, didChangePage: pageNum)
|
||||
}
|
||||
|
||||
private func trimPageDescriptorCache(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
let stalePages = pageDescriptors.keys.filter { abs($0 - pageIndex) > safeRadius }
|
||||
for cachedPage in stalePages {
|
||||
pageDescriptors.removeValue(forKey: cachedPage)
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -203,11 +296,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
guard descriptor == nil else { startOCRIfNeeded(index); return }
|
||||
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
|
||||
guard let self, descriptor.index == index else { return }
|
||||
self.pageDescriptors[index] = descriptor
|
||||
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded()
|
||||
if let page { self.configure(page, at: index) }
|
||||
DispatchQueue.main.async { [weak self, weak page] in
|
||||
guard let self, descriptor.index == index else { return }
|
||||
self.pageDescriptors[index] = descriptor
|
||||
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
|
||||
if let page { self.configure(page, at: index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,12 +315,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil,
|
||||
!recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return }
|
||||
recognizingPages.insert(index)
|
||||
recognizer.recognizeTextRuns(in: image) { [weak self] runs in
|
||||
guard let self else { return }
|
||||
self.recognizingPages.remove(index)
|
||||
self.ocrRuns[index] = runs
|
||||
self.refreshVisiblePage(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) {
|
||||
@@ -233,6 +361,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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] {
|
||||
do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] }
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] }
|
||||
@@ -248,12 +388,52 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
|
||||
private func showNavigation() {
|
||||
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems() ?? (0..<book.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
if let asyncOutlineProvider = pageProvider as? RDPDFReaderAsyncOutlineProviding {
|
||||
showNavigationLoading()
|
||||
asyncOutlineProvider.readerOutlineItems { [weak self] outline in
|
||||
DispatchQueue.main.async {
|
||||
guard let self 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 self?.pageProvider.readerThumbnail(at: index, targetSize: size, completion: completion) }, onSelectPage: { [weak self] page in self?.readerView.transitionToPage(pageNum: page, animated: false); self?.readerView.hideToolViewIfNeeded() })
|
||||
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
|
||||
}
|
||||
|
||||
private func showSettings() {
|
||||
let panel = RDPDFReaderSettingsPanelViewController(
|
||||
displayType: readerView.currentDisplayType,
|
||||
@@ -319,6 +499,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
/// 由外层 collectionView 承担,不能在画笔面板打开期间一概禁用。
|
||||
private func updateDrawingInteractionState() {
|
||||
readerView.setPagingEnabled(!isDrawingMode || currentDrawingTool == nil)
|
||||
readerView.setDrawingToolActive(isDrawingMode && currentDrawingTool != nil)
|
||||
configureVisibleDrawingPages()
|
||||
}
|
||||
|
||||
@@ -438,7 +619,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {}
|
||||
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) }
|
||||
@@ -475,10 +658,13 @@ 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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user