react-native-optimized-pdf 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -18
- package/CODE_OF_CONDUCT.md +128 -0
- package/LICENSE +21 -0
- package/README.md +23 -32
- package/ReactNativeOptimizedPdf.podspec +4 -4
- package/SECURITY.md +21 -0
- package/android/build.gradle +1 -1
- package/android/src/main/kotlin/com/reactnativeoptimizedpdf/OptimizedPdfView.kt +305 -143
- package/docs/android-setup.md +1 -1
- package/ios/Core/PDFConfiguration.swift +28 -0
- package/ios/Core/PDFDocumentLoader.swift +75 -0
- package/ios/Core/PDFError.swift +36 -0
- package/ios/ReactNative/OptimizedPDFViewManager.swift +48 -0
- package/ios/ReactNative/OptimizedPdfViewManager.m +54 -0
- package/ios/Views/OptimizedPDFView.swift +401 -0
- package/ios/Views/TiledPDFPageView.swift +312 -0
- package/package.json +19 -11
- package/src/OptimizedPdfView.tsx +20 -14
- package/src/components/PdfNavigationControls.tsx +1 -1
- package/src/components/PdfOverlays.tsx +3 -3
- package/src/types/index.ts +25 -0
- package/tsconfig.json +17 -10
- package/ios/OptimizedPdfView.swift +0 -256
- package/ios/OptimizedPdfViewManager.m +0 -22
- package/ios/OptimizedPdfViewManager.swift +0 -24
- package/ios/TiledPdfPageView.swift +0 -76
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
|
|
4
|
+
/// Protocol for PDF document loading operations.
|
|
5
|
+
protocol PDFDocumentLoading {
|
|
6
|
+
func loadDocument(from source: String, password: String?) -> Result<CGPDFDocument, PDFError>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Handles loading and unlocking PDF documents.
|
|
10
|
+
final class PDFDocumentLoader: PDFDocumentLoading {
|
|
11
|
+
|
|
12
|
+
// MARK: - Singleton
|
|
13
|
+
|
|
14
|
+
static let shared = PDFDocumentLoader()
|
|
15
|
+
|
|
16
|
+
private init() {}
|
|
17
|
+
|
|
18
|
+
// MARK: - Public Methods
|
|
19
|
+
|
|
20
|
+
/// Loads a PDF document from the given source path.
|
|
21
|
+
/// - Parameters:
|
|
22
|
+
/// - source: The file path to the PDF document.
|
|
23
|
+
/// - password: Optional password for encrypted PDFs.
|
|
24
|
+
/// - Returns: A Result containing the loaded document or an error.
|
|
25
|
+
func loadDocument(from source: String, password: String?) -> Result<CGPDFDocument, PDFError> {
|
|
26
|
+
guard !source.isEmpty else {
|
|
27
|
+
return .failure(.emptySource)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let url = normalizedURL(from: source)
|
|
31
|
+
|
|
32
|
+
guard let document = CGPDFDocument(url as CFURL) else {
|
|
33
|
+
return .failure(.documentLoadFailed(source))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return unlockIfNeeded(document: document, password: password)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MARK: - Private Methods
|
|
40
|
+
|
|
41
|
+
/// Normalizes the source path to a file URL.
|
|
42
|
+
private func normalizedURL(from source: String) -> URL {
|
|
43
|
+
let path = source.hasPrefix("file://")
|
|
44
|
+
? String(source.dropFirst(7))
|
|
45
|
+
: source
|
|
46
|
+
return URL(fileURLWithPath: path)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Attempts to unlock an encrypted PDF document.
|
|
50
|
+
private func unlockIfNeeded(
|
|
51
|
+
document: CGPDFDocument,
|
|
52
|
+
password: String?
|
|
53
|
+
) -> Result<CGPDFDocument, PDFError> {
|
|
54
|
+
guard document.isEncrypted else {
|
|
55
|
+
return .success(document)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Try empty password first (some PDFs use empty password for user access)
|
|
59
|
+
if document.unlockWithPassword("") {
|
|
60
|
+
return .success(document)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if password was provided
|
|
64
|
+
guard let password = password, !password.isEmpty else {
|
|
65
|
+
return .failure(.passwordRequired)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Try provided password
|
|
69
|
+
guard document.unlockWithPassword(password) else {
|
|
70
|
+
return .failure(.invalidPassword)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return .success(document)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Errors that can occur during PDF operations.
|
|
4
|
+
enum PDFError: Error, LocalizedError {
|
|
5
|
+
case emptySource
|
|
6
|
+
case invalidPath(String)
|
|
7
|
+
case documentLoadFailed(String)
|
|
8
|
+
case passwordRequired
|
|
9
|
+
case invalidPassword
|
|
10
|
+
case pageNotFound(Int)
|
|
11
|
+
case renderingFailed
|
|
12
|
+
|
|
13
|
+
var errorDescription: String? {
|
|
14
|
+
switch self {
|
|
15
|
+
case .emptySource:
|
|
16
|
+
return "PDF source path is empty"
|
|
17
|
+
case .invalidPath(let path):
|
|
18
|
+
return "Invalid PDF path: \(path)"
|
|
19
|
+
case .documentLoadFailed(let path):
|
|
20
|
+
return "Failed to open PDF at: \(path)"
|
|
21
|
+
case .passwordRequired:
|
|
22
|
+
return "PDF is password protected"
|
|
23
|
+
case .invalidPassword:
|
|
24
|
+
return "Invalid password for PDF"
|
|
25
|
+
case .pageNotFound(let index):
|
|
26
|
+
return "Page not found at index: \(index)"
|
|
27
|
+
case .renderingFailed:
|
|
28
|
+
return "Failed to render PDF page"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Dictionary representation for React Native events.
|
|
33
|
+
var eventPayload: [String: Any] {
|
|
34
|
+
["message": errorDescription ?? "Unknown error"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
/// React Native View Manager for OptimizedPDFView.
|
|
5
|
+
///
|
|
6
|
+
/// This class bridges the native OptimizedPDFView with React Native,
|
|
7
|
+
/// exposing properties and methods to the JavaScript layer.
|
|
8
|
+
@objc(OptimizedPdfViewManager)
|
|
9
|
+
final class OptimizedPDFViewManager: RCTViewManager {
|
|
10
|
+
|
|
11
|
+
// MARK: - RCTViewManager
|
|
12
|
+
|
|
13
|
+
override func view() -> UIView! {
|
|
14
|
+
OptimizedPDFView()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
override static func requiresMainQueueSetup() -> Bool {
|
|
18
|
+
true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// MARK: - Exported Methods
|
|
22
|
+
|
|
23
|
+
/// Navigates to a specific page in the PDF.
|
|
24
|
+
/// - Parameters:
|
|
25
|
+
/// - node: The React Native view tag.
|
|
26
|
+
/// - page: The page index to navigate to (0-based).
|
|
27
|
+
@objc func goToPage(_ node: NSNumber, page: NSNumber) {
|
|
28
|
+
DispatchQueue.main.async { [weak self] in
|
|
29
|
+
guard let self = self,
|
|
30
|
+
let pdfView = self.bridge?.uiManager.view(forReactTag: node) as? OptimizedPDFView else {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
pdfView.goToPage(page.intValue)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Resets the zoom level to fit the page.
|
|
38
|
+
/// - Parameter node: The React Native view tag.
|
|
39
|
+
@objc func resetZoom(_ node: NSNumber) {
|
|
40
|
+
DispatchQueue.main.async { [weak self] in
|
|
41
|
+
guard let self = self,
|
|
42
|
+
let pdfView = self.bridge?.uiManager.view(forReactTag: node) as? OptimizedPDFView else {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
pdfView.resetZoom()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native bridge for OptimizedPDFViewManager.
|
|
3
|
+
*
|
|
4
|
+
* This file exposes the Swift view manager and its properties/methods
|
|
5
|
+
* to the React Native JavaScript layer using the RCT_EXTERN macros.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
#import <React/RCTViewManager.h>
|
|
9
|
+
#import <React/RCTUIManager.h>
|
|
10
|
+
#import <React/RCTBridgeModule.h>
|
|
11
|
+
|
|
12
|
+
@interface RCT_EXTERN_MODULE(OptimizedPdfViewManager, RCTViewManager)
|
|
13
|
+
|
|
14
|
+
#pragma mark - View Properties
|
|
15
|
+
|
|
16
|
+
/// Path to the PDF file to display.
|
|
17
|
+
RCT_EXPORT_VIEW_PROPERTY(source, NSString)
|
|
18
|
+
|
|
19
|
+
/// Current page index (0-based).
|
|
20
|
+
RCT_EXPORT_VIEW_PROPERTY(page, NSNumber)
|
|
21
|
+
|
|
22
|
+
/// Maximum zoom scale allowed.
|
|
23
|
+
RCT_EXPORT_VIEW_PROPERTY(maximumZoom, NSNumber)
|
|
24
|
+
|
|
25
|
+
/// Whether antialiasing is enabled for smoother rendering.
|
|
26
|
+
RCT_EXPORT_VIEW_PROPERTY(enableAntialiasing, BOOL)
|
|
27
|
+
|
|
28
|
+
/// Password for encrypted PDFs.
|
|
29
|
+
RCT_EXPORT_VIEW_PROPERTY(password, NSString)
|
|
30
|
+
|
|
31
|
+
#pragma mark - Event Callbacks
|
|
32
|
+
|
|
33
|
+
/// Called when an error occurs during PDF loading or rendering.
|
|
34
|
+
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
|
|
35
|
+
|
|
36
|
+
/// Called when a page is fully loaded and displayed.
|
|
37
|
+
RCT_EXPORT_VIEW_PROPERTY(onLoadComplete, RCTDirectEventBlock)
|
|
38
|
+
|
|
39
|
+
/// Called when the document is loaded with the total page count.
|
|
40
|
+
RCT_EXPORT_VIEW_PROPERTY(onPageCount, RCTDirectEventBlock)
|
|
41
|
+
|
|
42
|
+
/// Called when the PDF requires a password to open.
|
|
43
|
+
RCT_EXPORT_VIEW_PROPERTY(onPasswordRequired, RCTDirectEventBlock)
|
|
44
|
+
|
|
45
|
+
#pragma mark - Native Methods
|
|
46
|
+
|
|
47
|
+
/// Navigates to a specific page in the PDF.
|
|
48
|
+
RCT_EXTERN_METHOD(goToPage:(nonnull NSNumber *)node
|
|
49
|
+
page:(nonnull NSNumber *)page)
|
|
50
|
+
|
|
51
|
+
/// Resets the zoom level to fit the page.
|
|
52
|
+
RCT_EXTERN_METHOD(resetZoom:(nonnull NSNumber *)node)
|
|
53
|
+
|
|
54
|
+
@end
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
import PDFKit
|
|
4
|
+
|
|
5
|
+
/// Protocol for receiving PDF view events.
|
|
6
|
+
@objc protocol OptimizedPDFViewDelegate: AnyObject {
|
|
7
|
+
@objc optional func pdfView(_ pdfView: OptimizedPDFView, didFailWithError error: Error)
|
|
8
|
+
@objc optional func pdfView(_ pdfView: OptimizedPDFView, didLoadWithPageCount pageCount: Int)
|
|
9
|
+
@objc optional func pdfView(_ pdfView: OptimizedPDFView, didDisplayPage page: Int, size: CGSize)
|
|
10
|
+
@objc optional func pdfViewDidRequestPassword(_ pdfView: OptimizedPDFView)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// A high-performance PDF view using CATiledLayer for smooth zooming and scrolling.
|
|
14
|
+
///
|
|
15
|
+
/// This view is optimized for React Native integration, providing callbacks for
|
|
16
|
+
/// load completion, errors, and page navigation events.
|
|
17
|
+
@objc(OptimizedPdfView)
|
|
18
|
+
final class OptimizedPDFView: UIScrollView {
|
|
19
|
+
|
|
20
|
+
// MARK: - React Native Properties
|
|
21
|
+
|
|
22
|
+
/// Path to the PDF file to display.
|
|
23
|
+
@objc var source: String = "" {
|
|
24
|
+
didSet {
|
|
25
|
+
handleSourceChange()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Current page index (0-based).
|
|
30
|
+
@objc var page: NSNumber = 0 {
|
|
31
|
+
didSet {
|
|
32
|
+
handlePageChange(to: page.intValue)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Maximum zoom scale.
|
|
37
|
+
@objc var maximumZoom: NSNumber = 5.0 {
|
|
38
|
+
didSet {
|
|
39
|
+
configuration.maximumZoom = CGFloat(truncating: maximumZoom)
|
|
40
|
+
applyZoomConfiguration()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Whether antialiasing is enabled.
|
|
45
|
+
@objc var enableAntialiasing: Bool = true {
|
|
46
|
+
didSet {
|
|
47
|
+
configuration.enableAntialiasing = enableAntialiasing
|
|
48
|
+
tiledPageView?.enableAntialiasing = enableAntialiasing
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Password for encrypted PDFs.
|
|
53
|
+
@objc var password: String = "" {
|
|
54
|
+
didSet {
|
|
55
|
+
configuration.password = password.isEmpty ? nil : password
|
|
56
|
+
retryLoadIfNeeded()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MARK: - React Native Event Callbacks
|
|
61
|
+
|
|
62
|
+
@objc var onError: RCTDirectEventBlock?
|
|
63
|
+
@objc var onLoadComplete: RCTDirectEventBlock?
|
|
64
|
+
@objc var onPageCount: RCTDirectEventBlock?
|
|
65
|
+
@objc var onPasswordRequired: RCTDirectEventBlock?
|
|
66
|
+
|
|
67
|
+
// MARK: - Public Properties
|
|
68
|
+
|
|
69
|
+
weak var pdfDelegate: OptimizedPDFViewDelegate?
|
|
70
|
+
|
|
71
|
+
/// Current number of pages in the document.
|
|
72
|
+
private(set) var pageCount: Int = 0
|
|
73
|
+
|
|
74
|
+
// MARK: - Private Properties
|
|
75
|
+
|
|
76
|
+
private var pdfDocument: CGPDFDocument?
|
|
77
|
+
private var tiledPageView: TiledPDFPageView!
|
|
78
|
+
private var pdfURL: URL?
|
|
79
|
+
private var configuration = PDFConfiguration.default
|
|
80
|
+
private let documentLoader: PDFDocumentLoading
|
|
81
|
+
|
|
82
|
+
private var pendingPageIndex: Int?
|
|
83
|
+
private var needsLoad = false
|
|
84
|
+
private var lastBoundsSize: CGSize = .zero
|
|
85
|
+
private var currentPageRect: CGRect?
|
|
86
|
+
private var initialZoomScale: CGFloat = 1.0
|
|
87
|
+
|
|
88
|
+
// MARK: - Initialization
|
|
89
|
+
|
|
90
|
+
override init(frame: CGRect) {
|
|
91
|
+
self.tiledPageView = TiledPDFPageView(frame: frame)
|
|
92
|
+
self.documentLoader = PDFDocumentLoader.shared
|
|
93
|
+
super.init(frame: frame)
|
|
94
|
+
setupView()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
required init?(coder: NSCoder) {
|
|
98
|
+
self.tiledPageView = TiledPDFPageView(frame: .zero)
|
|
99
|
+
self.documentLoader = PDFDocumentLoader.shared
|
|
100
|
+
super.init(coder: coder)
|
|
101
|
+
setupView()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Initializes with a custom document loader (useful for testing).
|
|
105
|
+
init(frame: CGRect, documentLoader: PDFDocumentLoading) {
|
|
106
|
+
self.tiledPageView = TiledPDFPageView(frame: frame)
|
|
107
|
+
self.documentLoader = documentLoader
|
|
108
|
+
super.init(frame: frame)
|
|
109
|
+
setupView()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// MARK: - Setup
|
|
113
|
+
|
|
114
|
+
private func setupView() {
|
|
115
|
+
setupScrollView()
|
|
116
|
+
setupTiledPageView()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func setupScrollView() {
|
|
120
|
+
delegate = self
|
|
121
|
+
showsHorizontalScrollIndicator = false
|
|
122
|
+
showsVerticalScrollIndicator = false
|
|
123
|
+
bouncesZoom = true
|
|
124
|
+
applyZoomConfiguration()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func setupTiledPageView() {
|
|
128
|
+
tiledPageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
129
|
+
tiledPageView.configuration = configuration
|
|
130
|
+
addSubview(tiledPageView)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func applyZoomConfiguration() {
|
|
134
|
+
maximumZoomScale = configuration.maximumZoom
|
|
135
|
+
|
|
136
|
+
guard let pageRect = currentPageRect else { return }
|
|
137
|
+
|
|
138
|
+
let fitScale = calculateFitScale(for: pageRect)
|
|
139
|
+
minimumZoomScale = fitScale
|
|
140
|
+
initialZoomScale = fitScale
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - Document Loading
|
|
145
|
+
|
|
146
|
+
private extension OptimizedPDFView {
|
|
147
|
+
|
|
148
|
+
func handleSourceChange() {
|
|
149
|
+
resetDocument()
|
|
150
|
+
pdfURL = nil
|
|
151
|
+
FlattenedPageCache.shared.removeAll()
|
|
152
|
+
pendingPageIndex = page.intValue
|
|
153
|
+
needsLoad = true
|
|
154
|
+
setNeedsLayout()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func handlePageChange(to index: Int) {
|
|
158
|
+
guard let document = pdfDocument else {
|
|
159
|
+
pendingPageIndex = index
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let validIndex = clampPageIndex(index, for: document)
|
|
164
|
+
displayPage(at: validIndex)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func retryLoadIfNeeded() {
|
|
168
|
+
guard !password.isEmpty, pdfDocument == nil, needsLoad else { return }
|
|
169
|
+
setNeedsLayout()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func resetDocument() {
|
|
173
|
+
pdfDocument = nil
|
|
174
|
+
pageCount = 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func loadDocument() {
|
|
178
|
+
guard !source.isEmpty else { return }
|
|
179
|
+
|
|
180
|
+
let path = source.hasPrefix("file://") ? String(source.dropFirst(7)) : source
|
|
181
|
+
pdfURL = URL(fileURLWithPath: path)
|
|
182
|
+
|
|
183
|
+
let result = documentLoader.loadDocument(from: source, password: configuration.password)
|
|
184
|
+
|
|
185
|
+
switch result {
|
|
186
|
+
case .success(let document):
|
|
187
|
+
handleLoadSuccess(document: document)
|
|
188
|
+
|
|
189
|
+
case .failure(let error):
|
|
190
|
+
handleLoadFailure(error: error)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func handleLoadSuccess(document: CGPDFDocument) {
|
|
195
|
+
pdfDocument = document
|
|
196
|
+
pageCount = document.numberOfPages
|
|
197
|
+
|
|
198
|
+
// Emit page count event on next run loop to ensure listeners are connected
|
|
199
|
+
DispatchQueue.main.async { [weak self] in
|
|
200
|
+
guard let self = self else { return }
|
|
201
|
+
self.onPageCount?(["numberOfPages": NSNumber(value: self.pageCount)])
|
|
202
|
+
self.pdfDelegate?.pdfView?(self, didLoadWithPageCount: self.pageCount)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Display pending or first page
|
|
206
|
+
let targetIndex = pendingPageIndex ?? 0
|
|
207
|
+
pendingPageIndex = nil
|
|
208
|
+
let validIndex = clampPageIndex(targetIndex, for: document)
|
|
209
|
+
displayPage(at: validIndex)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func handleLoadFailure(error: PDFError) {
|
|
213
|
+
if case .passwordRequired = error {
|
|
214
|
+
DispatchQueue.main.async { [weak self] in
|
|
215
|
+
guard let self = self else { return }
|
|
216
|
+
self.onPasswordRequired?([:])
|
|
217
|
+
self.pdfDelegate?.pdfViewDidRequestPassword?(self)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
onError?(error.eventPayload)
|
|
222
|
+
pdfDelegate?.pdfView?(self, didFailWithError: error)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func clampPageIndex(_ index: Int, for document: CGPDFDocument) -> Int {
|
|
226
|
+
max(0, min(index, document.numberOfPages - 1))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// MARK: - Page Display
|
|
231
|
+
|
|
232
|
+
private extension OptimizedPDFView {
|
|
233
|
+
|
|
234
|
+
func displayPage(at index: Int) {
|
|
235
|
+
guard let document = pdfDocument,
|
|
236
|
+
let page = document.page(at: index + 1) else {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let pageRect = page.getBoxRect(.cropBox)
|
|
241
|
+
currentPageRect = pageRect
|
|
242
|
+
|
|
243
|
+
prepareForPageDisplay()
|
|
244
|
+
|
|
245
|
+
tiledPageView?.cancel()
|
|
246
|
+
tiledPageView?.removeFromSuperview()
|
|
247
|
+
|
|
248
|
+
let newView = TiledPDFPageView(frame: CGRect(origin: .zero, size: pageRect.size))
|
|
249
|
+
newView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
250
|
+
newView.configuration = configuration
|
|
251
|
+
newView.pdfURL = pdfURL
|
|
252
|
+
newView.pageIndex = index
|
|
253
|
+
newView.pdfPage = page
|
|
254
|
+
addSubview(newView)
|
|
255
|
+
tiledPageView = newView
|
|
256
|
+
|
|
257
|
+
contentSize = pageRect.size
|
|
258
|
+
tiledPageView.preparePage()
|
|
259
|
+
|
|
260
|
+
applyFitScale(for: pageRect)
|
|
261
|
+
centerContent()
|
|
262
|
+
notifyPageDisplayed(index: index, size: pageRect.size)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func prepareForPageDisplay() {
|
|
266
|
+
zoomScale = 1.0
|
|
267
|
+
contentOffset = .zero
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func applyFitScale(for pageRect: CGRect) {
|
|
271
|
+
let fitScale = calculateFitScale(for: pageRect)
|
|
272
|
+
minimumZoomScale = fitScale
|
|
273
|
+
initialZoomScale = fitScale
|
|
274
|
+
setZoomScale(fitScale, animated: false)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func notifyPageDisplayed(index: Int, size: CGSize) {
|
|
278
|
+
let payload: [String: Any] = [
|
|
279
|
+
"currentPage": index + 1,
|
|
280
|
+
"width": size.width,
|
|
281
|
+
"height": size.height
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
DispatchQueue.main.async { [weak self] in
|
|
285
|
+
guard let self = self else { return }
|
|
286
|
+
self.onLoadComplete?(payload)
|
|
287
|
+
self.pdfDelegate?.pdfView?(self, didDisplayPage: index + 1, size: size)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// MARK: - Layout & Scaling
|
|
293
|
+
|
|
294
|
+
private extension OptimizedPDFView {
|
|
295
|
+
|
|
296
|
+
func calculateFitScale(for pageRect: CGRect) -> CGFloat {
|
|
297
|
+
guard bounds.width > 0,
|
|
298
|
+
bounds.height > 0,
|
|
299
|
+
pageRect.width > 0,
|
|
300
|
+
pageRect.height > 0 else {
|
|
301
|
+
return 1.0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let scaleX = bounds.width / pageRect.width
|
|
305
|
+
let scaleY = bounds.height / pageRect.height
|
|
306
|
+
let scale = min(scaleX, scaleY)
|
|
307
|
+
|
|
308
|
+
guard scale.isFinite, scale > 0 else {
|
|
309
|
+
return 1.0
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return scale
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func centerContent() {
|
|
316
|
+
let boundsSize = bounds.size
|
|
317
|
+
var frame = tiledPageView.frame
|
|
318
|
+
|
|
319
|
+
frame.origin.x = frame.width < boundsSize.width
|
|
320
|
+
? (boundsSize.width - frame.width) * 0.5
|
|
321
|
+
: 0
|
|
322
|
+
|
|
323
|
+
frame.origin.y = frame.height < boundsSize.height
|
|
324
|
+
? (boundsSize.height - frame.height) * 0.5
|
|
325
|
+
: 0
|
|
326
|
+
|
|
327
|
+
tiledPageView.frame = frame
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// MARK: - UIScrollViewDelegate
|
|
332
|
+
|
|
333
|
+
extension OptimizedPDFView: UIScrollViewDelegate {
|
|
334
|
+
|
|
335
|
+
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
336
|
+
tiledPageView
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
340
|
+
centerContent()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - Lifecycle
|
|
345
|
+
|
|
346
|
+
extension OptimizedPDFView {
|
|
347
|
+
|
|
348
|
+
override func didMoveToWindow() {
|
|
349
|
+
super.didMoveToWindow()
|
|
350
|
+
if window != nil {
|
|
351
|
+
setNeedsLayout()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
override func layoutSubviews() {
|
|
356
|
+
super.layoutSubviews()
|
|
357
|
+
|
|
358
|
+
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
359
|
+
|
|
360
|
+
if needsLoad {
|
|
361
|
+
needsLoad = false
|
|
362
|
+
loadDocument()
|
|
363
|
+
lastBoundsSize = bounds.size
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
handleBoundsChange()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private func handleBoundsChange() {
|
|
371
|
+
guard lastBoundsSize != bounds.size,
|
|
372
|
+
let pageRect = currentPageRect else {
|
|
373
|
+
centerContent()
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
lastBoundsSize = bounds.size
|
|
378
|
+
applyFitScale(for: pageRect)
|
|
379
|
+
centerContent()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// MARK: - Public Methods
|
|
384
|
+
|
|
385
|
+
extension OptimizedPDFView {
|
|
386
|
+
|
|
387
|
+
/// Resets the zoom to fit the page.
|
|
388
|
+
@objc func resetZoom() {
|
|
389
|
+
UIView.animate(withDuration: configuration.zoomResetAnimationDuration) { [weak self] in
|
|
390
|
+
guard let self = self else { return }
|
|
391
|
+
self.setZoomScale(self.initialZoomScale, animated: false)
|
|
392
|
+
self.centerContent()
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Navigates to a specific page.
|
|
397
|
+
/// - Parameter index: The page index (0-based).
|
|
398
|
+
@objc func goToPage(_ index: Int) {
|
|
399
|
+
page = NSNumber(value: index)
|
|
400
|
+
}
|
|
401
|
+
}
|