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.
@@ -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
+ }