Merge branch 'dev' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK into dev
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import PDFKit
|
||||
import CryptoKit
|
||||
|
||||
public enum RDPDFKitPageProviderError: LocalizedError {
|
||||
case cannotOpen(URL)
|
||||
@@ -28,6 +29,8 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
private let descriptor: RDPDFReaderBookDescriptor
|
||||
private let renderQueue = DispatchQueue(label: "com.readviewsdk.pdfkit.render", qos: .userInitiated)
|
||||
private let pageCache = NSCache<NSNumber, UIImage>()
|
||||
/// 仅在 `renderQueue` 上访问,用于让 NSCache 的非确定性淘汰之外仍遵守阅读窗口。
|
||||
private var cachedPageIndexes = Set<Int>()
|
||||
private let thumbnailCache = NSCache<NSString, UIImage>()
|
||||
private let maximumPagePixelDimension: CGFloat
|
||||
private let screenScale: CGFloat
|
||||
@@ -85,7 +88,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
profile: "cropbox-normalized-1"
|
||||
)
|
||||
: nil
|
||||
pageCache.countLimit = 6
|
||||
pageCache.countLimit = 5
|
||||
pageCache.totalCostLimit = 96 * 1024 * 1024
|
||||
thumbnailCache.countLimit = 80
|
||||
thumbnailCache.totalCostLimit = 24 * 1024 * 1024
|
||||
@@ -116,6 +119,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
let image = autoreleasepool { self.renderPage(page) }
|
||||
if let image {
|
||||
self.pageCache.setObject(image, forKey: NSNumber(value: index), cost: image.rdEstimatedMemoryCost)
|
||||
self.cachedPageIndexes.insert(index)
|
||||
}
|
||||
let runs = self.cachedTextRuns(for: index, page: page)
|
||||
DispatchQueue.main.async {
|
||||
@@ -159,9 +163,26 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
}
|
||||
|
||||
public func removeCachedImages() {
|
||||
pageCache.removeAllObjects()
|
||||
thumbnailCache.removeAllObjects()
|
||||
renderQueue.async { [weak self] in self?.textRunsCache.removeAll(keepingCapacity: false) }
|
||||
renderQueue.async { [weak self] in
|
||||
self?.pageCache.removeAllObjects()
|
||||
self?.thumbnailCache.removeAllObjects()
|
||||
self?.cachedPageIndexes.removeAll(keepingCapacity: false)
|
||||
self?.textRunsCache.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面大图只保留当前页前后的阅读窗口。被移除的页面不会写入磁盘,下次访问时
|
||||
/// 直接从 PDF 重绘;磁盘缓存仅保存 OCR/PDFKit 文本坐标。
|
||||
public func trimPageImages(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let stalePages = self.cachedPageIndexes.filter { abs($0 - pageIndex) > safeRadius }
|
||||
stalePages.forEach {
|
||||
self.pageCache.removeObject(forKey: NSNumber(value: $0))
|
||||
self.cachedPageIndexes.remove($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 与控制器的页面图片缓存窗口保持一致,避免原生文本结果在长 PDF 中无限累积。
|
||||
@@ -209,21 +230,25 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
return runs
|
||||
}
|
||||
|
||||
/// 身份签名只取文件名、大小和头部内容。绝对路径在 iOS 容器迁移(应用更新、
|
||||
/// 备份恢复)后会变化,修改时间在重新下载后会变化;它们参与签名会让以
|
||||
/// identifier 为 key 的书签、笔迹和阅读进度整体失效。
|
||||
/// 使用完整文件内容生成稳定身份。按块读取避免大 PDF 一次性进入内存;绝对路径、
|
||||
/// 修改时间不参与签名,因此应用容器迁移或重新下载不会丢失阅读状态。
|
||||
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))"
|
||||
}
|
||||
guard let handle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||
let fallback = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")"
|
||||
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: fallback))"
|
||||
}
|
||||
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: signature))"
|
||||
defer { handle.closeFile() }
|
||||
|
||||
var hasher = SHA256()
|
||||
while true {
|
||||
let chunk = handle.readData(ofLength: 1024 * 1024)
|
||||
guard chunk.isEmpty == false else { break }
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
return "pdf.sha256.\(digest)"
|
||||
}
|
||||
|
||||
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
|
||||
|
||||
@@ -143,6 +143,12 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
/// 手机横屏宽度适配下,远跳的目标页可能先按占位高度布局,待真实页面数据
|
||||
/// 到达后需要重新将目标页锚定到顶部。
|
||||
var pendingWidthFitJumpPage: Int?
|
||||
/// 宽度适配页的真实高度异步到达时,用页内相对位置恢复视口,避免前序 cell
|
||||
/// 从占位高度扩展后把正在阅读的内容顶走。
|
||||
private struct VerticalReadingAnchor {
|
||||
let page: Int
|
||||
let offsetFromPageTop: CGFloat
|
||||
}
|
||||
private var reusableTypes: [String: UIView.Type] = [:]
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@@ -314,29 +320,52 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
/// 宿主拿到页面数据时调用此方法刷新布局。
|
||||
public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
|
||||
guard usesWidthFitVerticalScroll else { return }
|
||||
let readingAnchor = verticalReadingAnchor()
|
||||
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
|
||||
guard let self, self.usesWidthFitVerticalScroll else { return }
|
||||
// 手指或惯性滚动拥有位置时不回写,避免与用户输入竞争。
|
||||
guard !self.collectionView.isTracking,
|
||||
!self.collectionView.isDragging,
|
||||
!self.collectionView.isDecelerating else {
|
||||
// 用户已接管显式跳页后的滚动位置,不能把遗留锚点留给后续任意
|
||||
// 页面加载,否则松手后会被意外拉回目标页顶部。
|
||||
if updatedPage == self.pendingWidthFitJumpPage {
|
||||
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
|
||||
if let updatedPage,
|
||||
updatedPage == self.pendingWidthFitJumpPage,
|
||||
self.currentPage == updatedPage {
|
||||
self.collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
|
||||
animated: false
|
||||
)
|
||||
self.pendingWidthFitJumpPage = nil
|
||||
} else if let readingAnchor {
|
||||
let targetY = self.anchorY(forItem: readingAnchor.page) + readingAnchor.offsetFromPageTop
|
||||
self.collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: self.clampedVerticalOffset(targetY)),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verticalReadingAnchor() -> VerticalReadingAnchor? {
|
||||
guard currentDisplayType == .verticalScroll,
|
||||
collectionView.contentSize.height > 0 else { return nil }
|
||||
let visibleY = max(0, collectionView.contentOffset.y)
|
||||
let point = CGPoint(x: collectionView.bounds.midX, y: visibleY + 1)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: point) else { return nil }
|
||||
return VerticalReadingAnchor(
|
||||
page: indexPath.item,
|
||||
offsetFromPageTop: visibleY - anchorY(forItem: indexPath.item)
|
||||
)
|
||||
}
|
||||
|
||||
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
|
||||
|
||||
/// 画笔/橡皮被选中时调用。放大层随之把单指手势让给页面画布,只保留双指拖动与捏合。
|
||||
@@ -708,7 +737,10 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
}
|
||||
|
||||
private func updateInsets() {
|
||||
let size = canvasView.frame.size
|
||||
// 缩放时 UIScrollView 会给 canvasView 加 transform,`frame` 已经是缩放后的
|
||||
// 视觉尺寸。若再乘 zoomScale,会在每个 pinch 回调中把 inset 多算一遍,
|
||||
// UIKit 随之修正 contentOffset,慢速捏合时看起来就像书页在跳动。
|
||||
let size = canvasView.bounds.size
|
||||
scrollView.contentInset = UIEdgeInsets(
|
||||
top: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
|
||||
left: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2),
|
||||
|
||||
@@ -94,19 +94,23 @@ public struct RDPDFReaderDrawingDocument: Codable {
|
||||
public let pageNo: Int
|
||||
public let paths: [RDPDFReaderDrawingPath]
|
||||
public let layers: [RDPDFReaderDrawingLayer]
|
||||
/// 乐观锁版本。旧文件缺少该字段时按 0 处理。
|
||||
public let revision: Int
|
||||
|
||||
public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = []) {
|
||||
public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = [], revision: Int = 0) {
|
||||
self.pageNo = pageNo
|
||||
self.paths = paths
|
||||
self.layers = layers
|
||||
self.revision = max(0, revision)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case pageNo, paths, layers }
|
||||
private enum CodingKeys: String, CodingKey { case pageNo, paths, layers, revision }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pageNo = try container.decode(Int.self, forKey: .pageNo)
|
||||
paths = try container.decode([RDPDFReaderDrawingPath].self, forKey: .paths)
|
||||
layers = try container.decodeIfPresent([RDPDFReaderDrawingLayer].self, forKey: .layers) ?? []
|
||||
revision = max(0, try container.decodeIfPresent(Int.self, forKey: .revision) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +128,14 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
public var strokeColor: UIColor = .black
|
||||
public var lineWidth: CGFloat = 2
|
||||
public var currentPage = 0
|
||||
/// 绘画会话内使用路径作为唯一渲染来源;退出会话后改用派生位图缓存。
|
||||
public var usesPathRendering = false {
|
||||
didSet {
|
||||
guard oldValue != usesPathRendering else { return }
|
||||
if !usesPathRendering { finishCurrentStroke() }
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
public var pathsChangedHandler: (([RDPDFReaderDrawingPath]) -> Void)?
|
||||
/// 将本页持续中的笔势交给展开页路由器。路由器可在书脊处把后续触点转发给相邻页。
|
||||
public var strokeEventHandler: ((CGPoint, RDPDFReaderDrawingStrokePhase) -> Void)?
|
||||
@@ -136,6 +148,15 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
private var undoStack: [DrawingAction] = []
|
||||
private var redoStack: [DrawingAction] = []
|
||||
private let maxUndoCount = 50
|
||||
/// 退出绘画会话后,已提交笔迹按图层缓存为位图,供普通阅读快速显示。
|
||||
private let committedLayerImages: NSCache<NSUUID, UIImage> = {
|
||||
let cache = NSCache<NSUUID, UIImage>()
|
||||
cache.countLimit = 4
|
||||
cache.totalCostLimit = 48 * 1024 * 1024
|
||||
return cache
|
||||
}()
|
||||
private var documentRevision = 0
|
||||
private var cachedRenderingSize = CGSize.zero
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -152,6 +173,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
public func load(document: RDPDFReaderDrawingDocument) {
|
||||
paths = document.paths
|
||||
layers = document.layers
|
||||
documentRevision = document.revision
|
||||
if layers.isEmpty {
|
||||
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
|
||||
layers = [defaultLayer]
|
||||
@@ -173,9 +195,18 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
currentPath = nil
|
||||
undoStack.removeAll()
|
||||
redoStack.removeAll()
|
||||
invalidateCommittedLayerImages()
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
// 页面缩放或旋转改变了 referencePageSize 的映射,旧缓存不能复用。
|
||||
guard cachedRenderingSize != bounds.size else { return }
|
||||
cachedRenderingSize = bounds.size
|
||||
invalidateCommittedLayerImages()
|
||||
}
|
||||
|
||||
/// 兼容旧调用方,未分层笔迹自动归入“图层 1”。
|
||||
public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
|
||||
|
||||
@@ -186,7 +217,11 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
commitCurrentStroke()
|
||||
activeDrawingTouch = nil
|
||||
}
|
||||
public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
|
||||
public func drawingDocument() -> RDPDFReaderDrawingDocument {
|
||||
.init(pageNo: currentPage, paths: paths, layers: layers, revision: documentRevision)
|
||||
}
|
||||
public func acknowledgePersistedRevision(_ revision: Int) { documentRevision = max(documentRevision, revision) }
|
||||
public func releaseRenderingCache() { invalidateCommittedLayerImages() }
|
||||
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
|
||||
public func selectedDrawingLayerID() -> UUID? { activeLayerID }
|
||||
|
||||
@@ -194,7 +229,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
let layer = RDPDFReaderDrawingLayer(name: "图层 \(layers.count + 1)")
|
||||
layers.insert(layer, at: 0)
|
||||
activeLayerID = layer.id
|
||||
didChangePaths()
|
||||
didChangePaths(affectedLayerIDs: [layer.id])
|
||||
return layer
|
||||
}
|
||||
|
||||
@@ -208,7 +243,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
guard let index = layers.firstIndex(where: { $0.id == id }) else { return }
|
||||
layers[index].isVisible = isVisible
|
||||
if activeLayerID == id, !isVisible { activeLayerID = layers.first(where: \.isVisible)?.id }
|
||||
didChangePaths()
|
||||
didChangePaths(affectedLayerIDs: [id])
|
||||
}
|
||||
|
||||
public func deleteLayer(id: UUID) {
|
||||
@@ -216,7 +251,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
paths.removeAll { $0.layerID == id }
|
||||
layers.remove(at: index)
|
||||
if activeLayerID == id { activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id }
|
||||
didChangePaths()
|
||||
didChangePaths(affectedLayerIDs: [id])
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
@@ -251,12 +286,22 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
|
||||
public override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
if usesPathRendering {
|
||||
drawUsingPaths(in: context)
|
||||
return
|
||||
}
|
||||
drawUsingCommittedImages()
|
||||
}
|
||||
|
||||
private func drawUsingPaths(in context: CGContext) {
|
||||
// 图层必须隔离合成:橡皮擦只清除所属图层,不能穿透到下方图层。
|
||||
layers.filter(\.isVisible).forEach { layer in
|
||||
context.saveGState()
|
||||
context.beginTransparencyLayer(auxiliaryInfo: nil)
|
||||
paths.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) }
|
||||
if let currentPath, currentPath.layerID == layer.id { draw(path: currentPath, in: context) }
|
||||
paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) }
|
||||
if let currentPath, currentPath.layerID == layer.id {
|
||||
draw(path: currentPath, in: context)
|
||||
}
|
||||
context.endTransparencyLayer()
|
||||
context.restoreGState()
|
||||
}
|
||||
@@ -265,6 +310,16 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
if let currentPath, currentPath.layerID == nil { draw(path: currentPath, in: context) }
|
||||
}
|
||||
|
||||
private func drawUsingCommittedImages() {
|
||||
layers.filter(\.isVisible).forEach { layer in
|
||||
committedLayerImage(for: layer)?.draw(in: bounds)
|
||||
}
|
||||
// 极旧数据通常会在 load 时迁入默认图层;这里保留安全兜底。
|
||||
if paths.contains(where: { $0.layerID == nil }), let context = UIGraphicsGetCurrentContext() {
|
||||
paths.filter { $0.layerID == nil }.forEach { draw(path: $0, in: context) }
|
||||
}
|
||||
}
|
||||
|
||||
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard activeTouchCount(in: event) == 1, let touch = touches.first else { cancelCurrentStroke(); return }
|
||||
activeDrawingTouch = touch
|
||||
@@ -317,7 +372,12 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
case .clear(let saved):
|
||||
if isUndo { paths = saved } else { paths.removeAll() }
|
||||
}
|
||||
didChangePaths()
|
||||
let affectedLayers: Set<UUID>
|
||||
switch action {
|
||||
case .add(let path): affectedLayers = Set([path.layerID].compactMap { $0 })
|
||||
case .remove(let paths), .clear(let paths): affectedLayers = Set(paths.compactMap(\.layerID))
|
||||
}
|
||||
didChangePaths(affectedLayerIDs: affectedLayers.isEmpty ? nil : affectedLayers)
|
||||
}
|
||||
|
||||
private func appendPoints(from touch: UITouch, event: UIEvent?) {
|
||||
@@ -336,10 +396,45 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
paths.append(path)
|
||||
record(.add(path))
|
||||
currentPath = nil
|
||||
didChangePaths()
|
||||
if let layerID = path.layerID {
|
||||
didChangePaths(affectedLayerIDs: [layerID])
|
||||
} else {
|
||||
didChangePaths()
|
||||
}
|
||||
}
|
||||
private func cancelCurrentStroke() { currentPath = nil; activeDrawingTouch = nil; setNeedsDisplay() }
|
||||
private func didChangePaths() { setNeedsDisplay(); pathsChangedHandler?(paths) }
|
||||
private func didChangePaths(affectedLayerIDs: Set<UUID>? = nil) {
|
||||
if let affectedLayerIDs {
|
||||
affectedLayerIDs.forEach { committedLayerImages.removeObject(forKey: $0 as NSUUID) }
|
||||
} else {
|
||||
invalidateCommittedLayerImages()
|
||||
}
|
||||
setNeedsDisplay()
|
||||
pathsChangedHandler?(paths)
|
||||
}
|
||||
|
||||
private func invalidateCommittedLayerImages() { committedLayerImages.removeAllObjects() }
|
||||
|
||||
private func committedLayerImage(for layer: RDPDFReaderDrawingLayer) -> UIImage? {
|
||||
let key = layer.id as NSUUID
|
||||
if let image = committedLayerImages.object(forKey: key) { return image }
|
||||
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.opaque = false
|
||||
// 单图层最多约 250 万像素;NSCache 再限制总成本,避免 iPad 多图层占满内存。
|
||||
let basePixels = max(1, bounds.width * bounds.height)
|
||||
let maximumScale = sqrt(2_500_000 / basePixels)
|
||||
format.scale = max(1, min(contentScaleFactor, maximumScale))
|
||||
let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { rendererContext in
|
||||
let layerContext = rendererContext.cgContext
|
||||
layerContext.beginTransparencyLayer(auxiliaryInfo: nil)
|
||||
paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: layerContext) }
|
||||
layerContext.endTransparencyLayer()
|
||||
}
|
||||
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
|
||||
committedLayerImages.setObject(image, forKey: key, cost: cost)
|
||||
return image
|
||||
}
|
||||
|
||||
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
|
||||
guard !path.points.isEmpty else { return }
|
||||
|
||||
@@ -21,7 +21,12 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
qos: .userInitiated
|
||||
)
|
||||
private let requestLock = NSLock()
|
||||
private var pendingRequests: [UUID: DispatchWorkItem] = [:]
|
||||
private struct PendingRequest {
|
||||
let workItem: DispatchWorkItem
|
||||
let completion: Completion
|
||||
}
|
||||
|
||||
private var pendingRequests: [UUID: PendingRequest] = [:]
|
||||
/// 已进入 `perform` 的请求。`DispatchWorkItem.cancel()` 中断不了它们,
|
||||
/// 必须对 VNRequest 本身调用 `cancel()`。
|
||||
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
|
||||
@@ -57,7 +62,7 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let observations = request.results as? [VNRecognizedTextObservation] ?? []
|
||||
let runs = Self.makeTextRuns(from: observations)
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
guard let completion = self.takeCompletion(for: requestID) else { return }
|
||||
self.deliver(runs, to: completion)
|
||||
}
|
||||
request.recognitionLevel = configuration.recognitionLevel
|
||||
@@ -73,27 +78,30 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
}
|
||||
self.unregisterActiveVisionRequest(requestID)
|
||||
if performFailed {
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
guard let completion = self.takeCompletion(for: requestID) else { return }
|
||||
self.deliver([], to: completion)
|
||||
}
|
||||
}
|
||||
requestLock.lock()
|
||||
pendingRequests[requestID] = workItem
|
||||
pendingRequests[requestID] = PendingRequest(workItem: workItem, completion: completion)
|
||||
requestLock.unlock()
|
||||
processingQueue.async(execute: workItem)
|
||||
return requestID
|
||||
}
|
||||
|
||||
/// 丢弃尚未开始的识别请求,并忽略正在执行请求的完成结果。
|
||||
/// 用于快速翻页后优先让当前停留页进入串行 OCR 队列。
|
||||
/// 取消尚未完成的识别请求。每个被取消的请求仍会以空结果完成一次,保证回调和
|
||||
/// Swift Concurrency 调用方不会因快速翻页永久挂起。
|
||||
public func cancelAllRequests() {
|
||||
requestLock.lock()
|
||||
let queued = pendingRequests.values
|
||||
let inFlight = activeVisionRequests.values
|
||||
let cancelled = Array(pendingRequests.values)
|
||||
let inFlight = Array(activeVisionRequests.values)
|
||||
pendingRequests.removeAll()
|
||||
activeVisionRequests.removeAll()
|
||||
requestLock.unlock()
|
||||
queued.forEach { $0.cancel() }
|
||||
cancelled.forEach {
|
||||
$0.workItem.cancel()
|
||||
deliver([], to: $0.completion)
|
||||
}
|
||||
// 让正在执行的识别尽快返回,当前停留页不必等整页识别跑完才能入队。
|
||||
inFlight.forEach { $0.cancel() }
|
||||
}
|
||||
@@ -211,7 +219,7 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard pendingRequests[requestID]?.isCancelled == false else { return false }
|
||||
guard pendingRequests[requestID]?.workItem.isCancelled == false else { return false }
|
||||
activeVisionRequests[requestID] = request
|
||||
return true
|
||||
}
|
||||
@@ -222,13 +230,11 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
requestLock.unlock()
|
||||
}
|
||||
|
||||
/// 仅活跃请求能取得完成权;被取消或已替代的请求结果必须丢弃。
|
||||
@discardableResult
|
||||
private func removeRequest(_ requestID: UUID) -> Bool {
|
||||
/// 仅活跃请求能取得一次完成权;取消与 Vision 回调竞争时不会重复完成。
|
||||
private func takeCompletion(for requestID: UUID) -> Completion? {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard let request = pendingRequests.removeValue(forKey: requestID), !request.isCancelled else { return false }
|
||||
return true
|
||||
return pendingRequests.removeValue(forKey: requestID)?.completion
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,12 +60,19 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
private let drawingCanvas = RDPDFReaderDrawingCanvasView()
|
||||
private let contentAccessibilityView = UIView()
|
||||
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
|
||||
private let pageLoadFailureButton = UIButton(type: .system)
|
||||
private var tappedHighlight: RDPDFReaderAnnotation?
|
||||
private var configuredDrawingPageIndex: Int?
|
||||
private var pageLoadRetryHandler: (() -> Void)?
|
||||
|
||||
/// 画笔层属于单个实际 PDF 页,而不是横屏双页容器;因此笔迹不能越过书脊。
|
||||
/// 画笔面板已打开时,禁用文本选区以保证单指手势可以用于拖动画面。
|
||||
public var isDrawingSessionActive = false {
|
||||
didSet {
|
||||
guard oldValue != isDrawingSessionActive else { return }
|
||||
// 退出会话前先提交最后一笔;随后普通阅读只显示由最终路径派生的缓存图片。
|
||||
if !isDrawingSessionActive { drawingCanvas.finishCurrentStroke() }
|
||||
drawingCanvas.usesPathRendering = isDrawingSessionActive
|
||||
textLayer.isSelectionEnabled = !isDrawingSessionActive
|
||||
if isDrawingSessionActive { textLayer.clearSelection() }
|
||||
}
|
||||
@@ -153,8 +160,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
document: RDPDFReaderDrawingDocument,
|
||||
documentChanged: @escaping (RDPDFReaderDrawingDocument) -> Void
|
||||
) {
|
||||
drawingCanvas.currentPage = pageIndex
|
||||
drawingCanvas.load(document: document)
|
||||
// OCR、主题或标注刷新会反复配置同一个页面。相同页不能重新 load,
|
||||
// 否则会清掉正在采集的 currentPath 和撤销栈。
|
||||
if configuredDrawingPageIndex != pageIndex {
|
||||
drawingCanvas.finishCurrentStroke()
|
||||
drawingCanvas.currentPage = pageIndex
|
||||
drawingCanvas.load(document: document)
|
||||
configuredDrawingPageIndex = pageIndex
|
||||
}
|
||||
drawingCanvas.pathsChangedHandler = { [weak drawingCanvas] _ in
|
||||
guard let drawingCanvas else { return }
|
||||
documentChanged(drawingCanvas.drawingDocument())
|
||||
@@ -180,6 +193,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
public func selectDrawingLayer(id: UUID) { drawingCanvas.selectLayer(id: id) }
|
||||
public func setDrawingLayerVisibility(id: UUID, isVisible: Bool) { drawingCanvas.setLayerVisibility(id: id, isVisible: isVisible) }
|
||||
public func deleteDrawingLayer(id: UUID) { drawingCanvas.deleteLayer(id: id) }
|
||||
func acknowledgeDrawingRevision(_ revision: Int) { drawingCanvas.acknowledgePersistedRevision(revision) }
|
||||
func releaseDrawingRenderingCache() { drawingCanvas.releaseRenderingCache() }
|
||||
|
||||
func setPageLoadFailed(_ failed: Bool, retryHandler: (() -> Void)? = nil) {
|
||||
pageLoadRetryHandler = retryHandler
|
||||
pageLoadFailureButton.isHidden = !failed
|
||||
pageLoadFailureButton.isEnabled = failed
|
||||
}
|
||||
|
||||
func containsDrawingPoint(_ point: CGPoint, from sourceView: UIView) -> Bool {
|
||||
drawingCanvas.bounds.contains(sourceView.convert(point, to: drawingCanvas))
|
||||
@@ -249,6 +270,15 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
|
||||
addSubview(selectionLoupe)
|
||||
|
||||
pageLoadFailureButton.setTitle("页面加载失败,点击重试", for: .normal)
|
||||
pageLoadFailureButton.titleLabel?.font = .preferredFont(forTextStyle: .body)
|
||||
pageLoadFailureButton.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.94)
|
||||
pageLoadFailureButton.layer.cornerRadius = 10
|
||||
pageLoadFailureButton.accessibilityIdentifier = "pdf.reader.page.retry"
|
||||
pageLoadFailureButton.addTarget(self, action: #selector(retryPageLoad), for: .touchUpInside)
|
||||
pageLoadFailureButton.isHidden = true
|
||||
addSubview(pageLoadFailureButton)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.delegate = self
|
||||
tap.require(toFail: zoomView.doubleTapZoomGestureRecognizer)
|
||||
@@ -266,6 +296,10 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
zoomView.layoutIfNeeded()
|
||||
textLayer.frame = zoomView.contentView.bounds
|
||||
drawingCanvas.frame = zoomView.contentView.bounds
|
||||
pageLoadFailureButton.sizeToFit()
|
||||
pageLoadFailureButton.bounds.size.width += 32
|
||||
pageLoadFailureButton.bounds.size.height += 24
|
||||
pageLoadFailureButton.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
}
|
||||
|
||||
// MARK: - 点击分发
|
||||
@@ -284,6 +318,13 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
readerContentTapHandler?(gesture.location(in: self))
|
||||
}
|
||||
|
||||
@objc private func retryPageLoad() { pageLoadRetryHandler?() }
|
||||
|
||||
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
// 重试按钮拥有自己的点击语义,不能同时触发页面点击/工具栏显隐。
|
||||
!(touch.view?.isDescendant(of: pageLoadFailureButton) ?? false)
|
||||
}
|
||||
|
||||
private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
||||
let normalizedPoint = CGPoint(
|
||||
|
||||
@@ -18,6 +18,10 @@ public enum RDPDFReaderPanelPresenter {
|
||||
layout: RDPDFReaderPanelLayout,
|
||||
hidesNavigationBar: Bool = true
|
||||
) {
|
||||
// 工具栏连续点击或异步目录回调可能在同一转场窗口内重复到达;UIKit 不支持
|
||||
// 同一 presenter 同时呈现两个控制器。
|
||||
guard presenter.presentedViewController == nil,
|
||||
!presenter.isBeingDismissed else { return }
|
||||
let transitionDelegate = RDPDFReaderPanelTransitionDelegate(layout: layout)
|
||||
objc_setAssociatedObject(controller, &transitionDelegateKey, transitionDelegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
controller.setNavigationBarHidden(hidesNavigationBar, animated: false)
|
||||
|
||||
@@ -7,6 +7,7 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
|
||||
case invalidDocument(URL, Error)
|
||||
case unsupportedDocumentVersion(URL, Int)
|
||||
case duplicateAnnotationID(String)
|
||||
case drawingConflict(URL, expectedRevision: Int, actualRevision: Int)
|
||||
case encodingFailed(Error)
|
||||
case writeFailed(URL, Error)
|
||||
|
||||
@@ -20,6 +21,8 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
|
||||
return "PDF 标注来自更新版本,当前版本无法安全保存。"
|
||||
case .duplicateAnnotationID:
|
||||
return "检测到重复的 PDF 标注标识。"
|
||||
case .drawingConflict:
|
||||
return "笔迹已在另一阅读器中更新,请重新载入后再试。"
|
||||
case .encodingFailed:
|
||||
return "无法编码 PDF 标注。"
|
||||
case .writeFailed:
|
||||
@@ -36,8 +39,8 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
|
||||
}
|
||||
|
||||
private static let annotationDocumentVersion = 1
|
||||
/// 同一进程内的多个阅读器可能指向同一本书;将读改写串行化,避免最后一次写入
|
||||
/// 覆盖掉另一实例刚创建的标注。
|
||||
/// 同一进程内的多个阅读器可能指向同一本书;将本地状态文件的读写串行化,
|
||||
/// 避免原子替换过程中出现竞争或读取半完成文件。
|
||||
private static let annotationPersistenceLock = NSLock()
|
||||
private let rootURL: URL
|
||||
private let drawingsURL: URL
|
||||
@@ -56,6 +59,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
|
||||
}
|
||||
|
||||
public func drawingDocument(pageNo: Int) -> RDPDFReaderDrawingDocument {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
return drawingDocumentLocked(pageNo: pageNo)
|
||||
}
|
||||
|
||||
private func drawingDocumentLocked(pageNo: Int) -> RDPDFReaderDrawingDocument {
|
||||
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
|
||||
guard let data = try? Data(contentsOf: url), let document = try? JSONDecoder().decode(RDPDFReaderDrawingDocument.self, from: data) else {
|
||||
return .init(pageNo: pageNo, paths: [])
|
||||
@@ -63,30 +72,90 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
|
||||
return document
|
||||
}
|
||||
|
||||
public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) {
|
||||
@discardableResult
|
||||
public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) -> Result<Void, RDPDFReaderAnnotationPersistenceError> {
|
||||
saveDrawingDocument(.init(pageNo: pageNo, paths: paths), pageNo: pageNo)
|
||||
}
|
||||
|
||||
public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) {
|
||||
guard let data = try? JSONEncoder().encode(document) else { return }
|
||||
write(data, to: drawingsURL.appendingPathComponent("\(pageNo).json"))
|
||||
@discardableResult
|
||||
public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) -> Result<Void, RDPDFReaderAnnotationPersistenceError> {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
|
||||
do {
|
||||
let data = try JSONEncoder().encode(document)
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return .success(())
|
||||
} catch let error as RDPDFReaderAnnotationPersistenceError {
|
||||
return .failure(error)
|
||||
} catch {
|
||||
return .failure(.writeFailed(url, error))
|
||||
}
|
||||
}
|
||||
|
||||
/// 成品阅读器使用的乐观锁保存。多实例持有同一页旧快照时拒绝覆盖,调用方可提示
|
||||
/// 用户重新载入;成功后返回递增 revision 供后续编辑继续提交。
|
||||
@discardableResult
|
||||
public func saveDrawingDocumentVersioned(
|
||||
_ document: RDPDFReaderDrawingDocument,
|
||||
pageNo: Int
|
||||
) -> Result<RDPDFReaderDrawingDocument, RDPDFReaderAnnotationPersistenceError> {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
|
||||
let existing = drawingDocumentLocked(pageNo: pageNo)
|
||||
guard document.revision == existing.revision else {
|
||||
return .failure(.drawingConflict(
|
||||
url,
|
||||
expectedRevision: document.revision,
|
||||
actualRevision: existing.revision
|
||||
))
|
||||
}
|
||||
let saved = RDPDFReaderDrawingDocument(
|
||||
pageNo: pageNo,
|
||||
paths: document.paths,
|
||||
layers: document.layers,
|
||||
revision: existing.revision + 1
|
||||
)
|
||||
do {
|
||||
let data = try JSONEncoder().encode(saved)
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return .success(saved)
|
||||
} catch {
|
||||
return .failure(.writeFailed(url, error))
|
||||
}
|
||||
}
|
||||
|
||||
public func highlights(pageIndex: Int) -> [RDPDFReaderHighlight] { allHighlights().filter { $0.pageIndex == pageIndex } }
|
||||
public func allHighlights() -> [RDPDFReaderHighlight] {
|
||||
guard let data = try? Data(contentsOf: highlightsURL) else { return [] }
|
||||
return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? []
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
return loadHighlightsLocked()
|
||||
}
|
||||
|
||||
public func addHighlight(pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) -> RDPDFReaderHighlight {
|
||||
let item = RDPDFReaderHighlight(id: Int(Date().timeIntervalSince1970 * 1000), pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect)
|
||||
var items = allHighlights()
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
var items = loadHighlightsLocked()
|
||||
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||
var uniqueID = timestamp
|
||||
let existingIDs = Set(items.map(\.id))
|
||||
while existingIDs.contains(uniqueID), uniqueID < Int.max { uniqueID += 1 }
|
||||
let item = RDPDFReaderHighlight(id: uniqueID, pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect)
|
||||
items.append(item)
|
||||
saveHighlights(items)
|
||||
saveHighlightsLocked(items)
|
||||
return item
|
||||
}
|
||||
|
||||
public func deleteHighlight(id: Int) { saveHighlights(allHighlights().filter { $0.id != id }) }
|
||||
public func deleteHighlight(id: Int) {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
saveHighlightsLocked(loadHighlightsLocked().filter { $0.id != id })
|
||||
}
|
||||
|
||||
/// 返回当前书籍的全部高亮/注释记录。此兼容方法在文件不可读时返回空数组;
|
||||
/// 新代码若需要区分“没有标注”和“文件不可读”,请使用 `loadAnnotations()`。
|
||||
@@ -194,7 +263,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
|
||||
try saveAnnotationsLocked([])
|
||||
}
|
||||
|
||||
private func saveHighlights(_ highlights: [RDPDFReaderHighlight]) {
|
||||
private func loadHighlightsLocked() -> [RDPDFReaderHighlight] {
|
||||
guard let data = try? Data(contentsOf: highlightsURL) else { return [] }
|
||||
return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
private func saveHighlightsLocked(_ highlights: [RDPDFReaderHighlight]) {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else { return }
|
||||
write(data, to: highlightsURL)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
public var nativeTextDiskCacheVersion = 1
|
||||
/// 未提供文字且 OCR 关闭时,页面仍可使用区域标注。
|
||||
public var missingTextSource: RDPDFReaderAnnotationSource = .region
|
||||
/// SDK 直读 PDF 时,控制器保留当前页前后页面描述的半径,避免长 PDF 阅读时
|
||||
/// 持续持有已离开的页面图片。宿主 Provider 的原有缓存行为保持不变。
|
||||
public var pageDescriptorCacheRadius = 3
|
||||
/// 控制器默认只保留当前页及前后各两页的页面描述。SDK 直读 PDF 时,内置
|
||||
/// Provider 的页面大图缓存也会同步收窄到该窗口;离开窗口的页面按需重绘。
|
||||
public var pageDescriptorCacheRadius = 2
|
||||
/// 内存中保留当前页前后的 OCR 结果数量;默认沿用页面描述缓存半径。
|
||||
public var ocrMemoryCacheRadius: Int?
|
||||
/// 宿主或内置 Provider 提供页面后,可在此进行反色或其它主题渲染。
|
||||
@@ -48,15 +48,26 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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 speechHighlight: (pageIndex: Int, rects: [CGRect])?
|
||||
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` 时处于画笔面板内的浏览状态,可单指拖动画面。
|
||||
@@ -135,6 +146,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
readerView.dataSource = self
|
||||
readerView.delegate = self
|
||||
reloadAnnotationCache()
|
||||
readerView.currentDisplayType = configuration.displayType
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
// 手机横屏竖滑的 cell 高度 = 安全区内可用宽度按页面纵横比换算的纸张高度。
|
||||
@@ -155,6 +167,12 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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()
|
||||
@@ -163,7 +181,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -172,6 +192,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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
|
||||
@@ -305,6 +332,11 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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
|
||||
@@ -318,10 +350,61 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
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)
|
||||
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) {
|
||||
@@ -343,9 +426,17 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
|
||||
page.configureDrawing(
|
||||
pageIndex: index,
|
||||
document: (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: index) ?? .init(pageNo: index, paths: []),
|
||||
documentChanged: { [weak self] document in
|
||||
(self?.annotationPersistence as? RDPDFReaderPersistenceStore)?.saveDrawingDocument(document, pageNo: 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 {
|
||||
@@ -355,18 +446,70 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
if phase == .began { self?.activeDrawingPage = source }
|
||||
self?.routeDrawingStroke(from: source, point: point, phase: phase)
|
||||
}
|
||||
guard descriptor == nil else { startOCRIfNeeded(index); return }
|
||||
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
|
||||
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) }
|
||||
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
|
||||
@@ -434,9 +577,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
.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 [] }
|
||||
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() {
|
||||
@@ -450,10 +606,14 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -493,6 +653,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
navigationLoadingIndicator?.stopAnimating()
|
||||
navigationLoadingIndicator?.removeFromSuperview()
|
||||
navigationLoadingIndicator = nil
|
||||
navigationRequestToken = nil
|
||||
}
|
||||
|
||||
private func showSettings() {
|
||||
@@ -648,8 +809,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
|
||||
private func showAnnotations() {
|
||||
guard let annotationPersistence else { return }
|
||||
let panel = RDPDFReaderAnnotationListViewController { (try? annotationPersistence.loadAnnotations()) ?? [] }
|
||||
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)
|
||||
@@ -670,12 +831,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
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)); refreshVisiblePage(page) }
|
||||
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); refreshVisiblePage(annotation.pageIndex) }
|
||||
do {
|
||||
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
|
||||
reloadAnnotationCache()
|
||||
refreshVisiblePage(annotation.pageIndex)
|
||||
}
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
|
||||
}
|
||||
|
||||
@@ -699,6 +868,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
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) }
|
||||
|
||||
Reference in New Issue
Block a user