feat: optimize reader caching and document formats

This commit is contained in:
shenlei
2026-07-20 17:22:06 +09:00
parent 52f803e8db
commit 68d9363f0a
35 changed files with 3771 additions and 2229 deletions
@@ -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
+26
View File
@@ -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))
}
/// 使 CatmullRom
@@ -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) {}
}