react-native-pdfrender 0.1.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.
Files changed (68) hide show
  1. package/README.md +306 -0
  2. package/android/build.gradle +76 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/com/pdfrender/ComposeRenderer.kt +85 -0
  5. package/android/src/main/java/com/pdfrender/PdfCacheManager.kt +150 -0
  6. package/android/src/main/java/com/pdfrender/PdfConstants.kt +63 -0
  7. package/android/src/main/java/com/pdfrender/PdfIconComponents.kt +275 -0
  8. package/android/src/main/java/com/pdfrender/PdfRenderingLogic.kt +325 -0
  9. package/android/src/main/java/com/pdfrender/PdfUIComponents.kt +335 -0
  10. package/android/src/main/java/com/pdfrender/PdfViewPackage.kt +32 -0
  11. package/android/src/main/java/com/pdfrender/PdfViewerActivity.kt +3467 -0
  12. package/android/src/main/java/com/pdfrender/PdfViewerFabricManager.kt +244 -0
  13. package/android/src/main/java/com/pdfrender/PdfViewerFragment.kt +129 -0
  14. package/android/src/main/java/com/pdfrender/PdfViewerTurboModule.kt +158 -0
  15. package/android/src/main/java/com/pdfrender/events/FullScreenChangeEvent.kt +26 -0
  16. package/android/src/main/java/com/pdfrender/events/LeftScreenChangeEvent.kt +22 -0
  17. package/android/src/main/java/com/pdfrender/events/RightScreenChangeEvent.kt +22 -0
  18. package/android/src/main/java/com/pdfrender/events/ZoomChangeEvent.kt +22 -0
  19. package/ios/PdfCacheManager.swift +44 -0
  20. package/ios/PdfConstants.swift +38 -0
  21. package/ios/PdfPageView.swift +121 -0
  22. package/ios/PdfRenderingLogic.swift +107 -0
  23. package/ios/PdfToolbarView.swift +158 -0
  24. package/ios/PdfViewerComponentView.mm +194 -0
  25. package/ios/PdfViewerTurboModule.mm +186 -0
  26. package/ios/PdfViewerTurboModuleImpl.swift +141 -0
  27. package/ios/PdfViewerView.swift +268 -0
  28. package/ios/PdfViewerViewController.swift +109 -0
  29. package/lib/commonjs/PdfViewerView.js +105 -0
  30. package/lib/commonjs/PdfViewerView.js.map +1 -0
  31. package/lib/commonjs/index.js +28 -0
  32. package/lib/commonjs/index.js.map +1 -0
  33. package/lib/commonjs/package.json +1 -0
  34. package/lib/commonjs/specs/NativePdfViewerComponent.js +27 -0
  35. package/lib/commonjs/specs/NativePdfViewerComponent.js.map +1 -0
  36. package/lib/commonjs/specs/NativePdfViewerModule.js +21 -0
  37. package/lib/commonjs/specs/NativePdfViewerModule.js.map +1 -0
  38. package/lib/commonjs/usePdfViewer.js +65 -0
  39. package/lib/commonjs/usePdfViewer.js.map +1 -0
  40. package/lib/module/PdfViewerView.js +99 -0
  41. package/lib/module/PdfViewerView.js.map +1 -0
  42. package/lib/module/index.js +8 -0
  43. package/lib/module/index.js.map +1 -0
  44. package/lib/module/package.json +1 -0
  45. package/lib/module/specs/NativePdfViewerComponent.js +26 -0
  46. package/lib/module/specs/NativePdfViewerComponent.js.map +1 -0
  47. package/lib/module/specs/NativePdfViewerModule.js +18 -0
  48. package/lib/module/specs/NativePdfViewerModule.js.map +1 -0
  49. package/lib/module/usePdfViewer.js +60 -0
  50. package/lib/module/usePdfViewer.js.map +1 -0
  51. package/lib/typescript/PdfViewerView.d.ts +58 -0
  52. package/lib/typescript/PdfViewerView.d.ts.map +1 -0
  53. package/lib/typescript/index.d.ts +7 -0
  54. package/lib/typescript/index.d.ts.map +1 -0
  55. package/lib/typescript/specs/NativePdfViewerComponent.d.ts +59 -0
  56. package/lib/typescript/specs/NativePdfViewerComponent.d.ts.map +1 -0
  57. package/lib/typescript/specs/NativePdfViewerModule.d.ts +47 -0
  58. package/lib/typescript/specs/NativePdfViewerModule.d.ts.map +1 -0
  59. package/lib/typescript/usePdfViewer.d.ts +45 -0
  60. package/lib/typescript/usePdfViewer.d.ts.map +1 -0
  61. package/package.json +109 -0
  62. package/react-native-pdfrender.podspec +35 -0
  63. package/react-native.config.js +11 -0
  64. package/src/PdfViewerView.tsx +159 -0
  65. package/src/index.tsx +10 -0
  66. package/src/specs/NativePdfViewerComponent.ts +94 -0
  67. package/src/specs/NativePdfViewerModule.ts +58 -0
  68. package/src/usePdfViewer.ts +102 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * PdfViewerTurboModule.mm — ObjC++ bridge for the PDF viewer TurboModule.
3
+ *
4
+ * Architecture
5
+ * ────────────
6
+ * Swift class (PdfViewerTurboModuleImpl.swift)
7
+ * └─ holds ALL business logic (present VC, render pages, create PDF)
8
+ * This ObjC++ file
9
+ * └─ bridges Swift ↔ React Native New Architecture (JSI / TurboModules)
10
+ * └─ wraps promise-based methods (RCTPromiseResolveBlock / RejectBlock)
11
+ * └─ emits native events to JS via RCTEventEmitter
12
+ *
13
+ * Why .mm (ObjC++)?
14
+ * The New Architecture TurboModule system uses C++ (JSI) internally.
15
+ * `getTurboModule:` returns a std::shared_ptr — a C++ type — which
16
+ * cannot appear in a plain .m (ObjC) file.
17
+ *
18
+ * Why forward-declare instead of importing pdfRender-Swift.h?
19
+ * The auto-generated Swift bridging header pulls in every @objc class,
20
+ * including AppDelegate's ReactNativeDelegate which depends on
21
+ * RCTDefaultReactNativeFactoryDelegate — a transitive header chain that
22
+ * causes "file not found" errors in this translation unit. A targeted
23
+ * forward declaration breaks the dependency and the linker resolves the
24
+ * Swift symbol at link time.
25
+ *
26
+ * Module name
27
+ * RCT_EXPORT_MODULE(NativePdfViewerModule) must match the name returned by
28
+ * TurboModuleRegistry.getEnforcing('NativePdfViewerModule') in the TS spec.
29
+ */
30
+
31
+ #import <React/RCTBridgeModule.h>
32
+ #import <React/RCTEventEmitter.h>
33
+
34
+ // ── Forward-declare Swift class ───────────────────────────────────────────────
35
+ // Only declare the exact selectors used in this file; the linker resolves the
36
+ // real Swift symbols at link time. Adding new Swift methods requires adding
37
+ // the corresponding declaration here.
38
+
39
+ @interface PdfViewerTurboModuleImpl : NSObject
40
+
41
+ + (BOOL)requiresMainQueueSetup;
42
+
43
+ - (void)openViewerWithUri:(NSString *)uri
44
+ pageIndex:(double)pageIndex
45
+ defaultZoom:(double)defaultZoom
46
+ onFullScreenChange:(void (^)(BOOL))onFullScreenChange
47
+ onZoomChange:(void (^)(float))onZoomChange
48
+ onDismiss:(void (^)(void))onDismiss;
49
+
50
+ - (void)dismissViewer;
51
+
52
+ - (NSInteger)getPageCountWithUri:(NSString *)uri;
53
+
54
+ - (NSDictionary * _Nullable)createPdfWithPages:(NSArray<NSDictionary *> *)pages
55
+ widthPx:(double)widthPx
56
+ heightPx:(double)heightPx;
57
+
58
+ @end
59
+
60
+ // ── RCT_NEW_ARCH_ENABLED guard ────────────────────────────────────────────────
61
+ // Allows the same file to compile for both old and new architecture.
62
+ // RN 0.74+ enables new arch by default; the guard keeps migration safe.
63
+
64
+ #ifdef RCT_NEW_ARCH_ENABLED
65
+ #import <PdfViewerSpecs/PdfViewerSpecs.h>
66
+
67
+ @interface PdfViewerTurboModule : RCTEventEmitter <NativePdfViewerModuleSpec>
68
+ @end
69
+
70
+ #else
71
+
72
+ @interface PdfViewerTurboModule : RCTEventEmitter <RCTBridgeModule>
73
+ @end
74
+
75
+ #endif
76
+
77
+ // ── Implementation ────────────────────────────────────────────────────────────
78
+
79
+ @implementation PdfViewerTurboModule {
80
+ PdfViewerTurboModuleImpl *_impl;
81
+ }
82
+
83
+ RCT_EXPORT_MODULE(NativePdfViewerModule)
84
+
85
+ - (instancetype)init {
86
+ if (self = [super init]) {
87
+ _impl = [[PdfViewerTurboModuleImpl alloc] init];
88
+ }
89
+ return self;
90
+ }
91
+
92
+ + (BOOL)requiresMainQueueSetup { return NO; }
93
+
94
+ // Events emitted to JS (matches usePdfViewer.ts subscriptions)
95
+ - (NSArray<NSString *> *)supportedEvents {
96
+ return @[
97
+ @"PdfFullScreenChanged",
98
+ @"PdfZoomChanged",
99
+ @"PdfViewerDismissed",
100
+ @"PdfViewerError",
101
+ ];
102
+ }
103
+
104
+ // ── openPdfViewer ─────────────────────────────────────────────────────────────
105
+ // Dispatches to the main thread; Swift impl presents PdfViewerViewController.
106
+
107
+ RCT_EXPORT_METHOD(openPdfViewer:(NSString *)uri
108
+ pageIndex:(double)pageIndex
109
+ defaultZoom:(double)defaultZoom)
110
+ {
111
+ dispatch_async(dispatch_get_main_queue(), ^{
112
+ [self->_impl openViewerWithUri:uri
113
+ pageIndex:pageIndex
114
+ defaultZoom:defaultZoom
115
+ onFullScreenChange:^(BOOL isFS) {
116
+ [self sendEventWithName:@"PdfFullScreenChanged"
117
+ body:@{@"isFullScreen": @(isFS)}];
118
+ }
119
+ onZoomChange:^(float zoom) {
120
+ [self sendEventWithName:@"PdfZoomChanged"
121
+ body:@{@"zoomPercentage": @(zoom)}];
122
+ }
123
+ onDismiss:^{
124
+ [self sendEventWithName:@"PdfViewerDismissed" body:@{}];
125
+ }];
126
+ });
127
+ }
128
+
129
+ // ── dismissPdfViewer ──────────────────────────────────────────────────────────
130
+
131
+ RCT_EXPORT_METHOD(dismissPdfViewer)
132
+ {
133
+ dispatch_async(dispatch_get_main_queue(), ^{
134
+ [self->_impl dismissViewer];
135
+ });
136
+ }
137
+
138
+ // ── getPdfPageCount ───────────────────────────────────────────────────────────
139
+ // File I/O happens on a background queue; promise is resolved/rejected there.
140
+
141
+ RCT_EXPORT_METHOD(getPdfPageCount:(NSString *)uri
142
+ resolve:(RCTPromiseResolveBlock)resolve
143
+ reject:(RCTPromiseRejectBlock)reject)
144
+ {
145
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
146
+ NSInteger count = [self->_impl getPageCountWithUri:uri];
147
+ if (count > 0) {
148
+ resolve(@(count));
149
+ } else {
150
+ reject(@"PDF_ERROR", @"Could not open PDF or file has 0 pages", nil);
151
+ }
152
+ });
153
+ }
154
+
155
+ // ── createMultiPagePdfBase64 ──────────────────────────────────────────────────
156
+ // PDF generation is CPU-bound; run on a background queue.
157
+
158
+ RCT_EXPORT_METHOD(createMultiPagePdfBase64:(NSArray<NSDictionary *> *)pages
159
+ widthPx:(double)widthPx
160
+ heightPx:(double)heightPx
161
+ resolve:(RCTPromiseResolveBlock)resolve
162
+ reject:(RCTPromiseRejectBlock)reject)
163
+ {
164
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
165
+ NSDictionary *result = [self->_impl createPdfWithPages:pages
166
+ widthPx:widthPx
167
+ heightPx:heightPx];
168
+ if (result) {
169
+ resolve(result);
170
+ } else {
171
+ reject(@"PDF_CREATE_ERROR", @"Failed to generate PDF", nil);
172
+ }
173
+ });
174
+ }
175
+
176
+ // ── New Architecture JSI bridge ───────────────────────────────────────────────
177
+
178
+ #ifdef RCT_NEW_ARCH_ENABLED
179
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
180
+ (const facebook::react::ObjCTurboModule::InitParams &)params
181
+ {
182
+ return std::make_shared<facebook::react::NativePdfViewerModuleSpecJSI>(params);
183
+ }
184
+ #endif
185
+
186
+ @end
@@ -0,0 +1,141 @@
1
+ import UIKit
2
+
3
+ /// Pure Swift business logic for the PDF viewer TurboModule.
4
+ ///
5
+ /// The ObjC++ bridge (PdfViewerTurboModule.mm) forward-declares this class
6
+ /// and calls into it. All @objc annotations use explicit ObjC selector names
7
+ /// to guarantee the bridge calls the correct method regardless of Swift's
8
+ /// automatic ObjC name generation rules.
9
+ ///
10
+ /// Threading contract:
11
+ /// - openViewer / dismissViewer must be called on the MAIN thread by the bridge.
12
+ /// - getPageCount / createPdf are synchronous and must be called on a
13
+ /// BACKGROUND thread by the bridge, which wraps them in promise blocks.
14
+ @objc(PdfViewerTurboModuleImpl)
15
+ final class PdfViewerTurboModuleImpl: NSObject {
16
+
17
+ // Weak reference so ARC cleans up when the user dismisses
18
+ private weak var visibleVC: PdfViewerViewController?
19
+
20
+ @objc static func requiresMainQueueSetup() -> Bool { false }
21
+
22
+ // MARK: - openViewer (main thread)
23
+
24
+ /// Present PdfViewerViewController over the current key window root VC.
25
+ /// Selector: openViewerWithUri:pageIndex:defaultZoom:onFullScreenChange:onZoomChange:onDismiss:
26
+ @objc(openViewerWithUri:pageIndex:defaultZoom:onFullScreenChange:onZoomChange:onDismiss:)
27
+ func openViewer(
28
+ uri: String,
29
+ pageIndex: Double,
30
+ defaultZoom: Double,
31
+ onFullScreenChange: @escaping (Bool) -> Void,
32
+ onZoomChange: @escaping (Float) -> Void,
33
+ onDismiss: @escaping () -> Void
34
+ ) {
35
+ guard let presenter = topPresentedViewController() else { return }
36
+
37
+ let vc = PdfViewerViewController.make(
38
+ uri: uri,
39
+ pageIndex: Int(pageIndex),
40
+ defaultZoom: Float(defaultZoom)
41
+ )
42
+ vc.onFullScreenChange = { fs in onFullScreenChange(fs) }
43
+ vc.onZoomChange = { pct in onZoomChange(pct) }
44
+ vc.onDismiss = { onDismiss() }
45
+ vc.modalPresentationStyle = .overFullScreen
46
+
47
+ presenter.present(vc, animated: true)
48
+ visibleVC = vc
49
+ }
50
+
51
+ // MARK: - dismissViewer (main thread)
52
+
53
+ /// Selector: dismissViewer
54
+ @objc(dismissViewer)
55
+ func dismissViewer() {
56
+ visibleVC?.dismiss(animated: true)
57
+ }
58
+
59
+ // MARK: - getPageCount (background thread, synchronous)
60
+
61
+ /// Returns page count for the given URI.
62
+ /// Selector: getPageCountWithUri:
63
+ @objc(getPageCountWithUri:)
64
+ func getPageCount(uri: String) -> Int {
65
+ PdfRenderingLogic.pageCount(uri: uri)
66
+ }
67
+
68
+ // MARK: - createPdf (background thread, synchronous)
69
+
70
+ /// Renders structured pages into a PDF and returns base64 + temp file path.
71
+ /// Returns nil on failure (caller should reject the promise).
72
+ /// Selector: createPdfWithPages:widthPx:heightPx:
73
+ @objc(createPdfWithPages:widthPx:heightPx:)
74
+ func createPdf(
75
+ pages: [[String: Any]],
76
+ widthPx: Double,
77
+ heightPx: Double
78
+ ) -> [String: Any]? {
79
+ let pageSize = CGSize(width: widthPx, height: heightPx)
80
+ let data = NSMutableData()
81
+
82
+ UIGraphicsBeginPDFContextToData(data, CGRect(origin: .zero, size: pageSize), nil)
83
+
84
+ for page in pages {
85
+ let title = page["title"] as? String ?? ""
86
+ let body = page["body"] as? String ?? ""
87
+
88
+ UIGraphicsBeginPDFPage()
89
+ guard let ctx = UIGraphicsGetCurrentContext() else { continue }
90
+
91
+ // White background
92
+ ctx.setFillColor(UIColor.white.cgColor)
93
+ ctx.fill(CGRect(origin: .zero, size: pageSize))
94
+
95
+ // Title
96
+ let titleAttrs: [NSAttributedString.Key: Any] = [
97
+ .font: UIFont.boldSystemFont(ofSize: 24),
98
+ .foregroundColor: UIColor.black,
99
+ ]
100
+ title.draw(at: CGPoint(x: 40, y: 40), withAttributes: titleAttrs)
101
+
102
+ // Body
103
+ let bodyAttrs: [NSAttributedString.Key: Any] = [
104
+ .font: UIFont.systemFont(ofSize: 16),
105
+ .foregroundColor: UIColor.darkGray,
106
+ ]
107
+ let bodyRect = CGRect(x: 40, y: 90, width: pageSize.width - 80, height: pageSize.height - 130)
108
+ body.draw(in: bodyRect, withAttributes: bodyAttrs)
109
+ }
110
+
111
+ UIGraphicsEndPDFContext()
112
+
113
+ let base64 = data.base64EncodedString(options: [])
114
+ let filename = "generated_\(Int(Date().timeIntervalSince1970)).pdf"
115
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
116
+
117
+ do {
118
+ try data.write(to: tempURL, options: .atomic)
119
+ return ["base64": base64, "path": tempURL.path]
120
+ } catch {
121
+ return nil
122
+ }
123
+ }
124
+
125
+ // MARK: - Helpers
126
+
127
+ /// Walks the presentation chain to find the topmost presented VC.
128
+ private func topPresentedViewController() -> UIViewController? {
129
+ guard let scene = UIApplication.shared.connectedScenes
130
+ .compactMap({ $0 as? UIWindowScene })
131
+ .first,
132
+ let root = scene.windows.first(where: \.isKeyWindow)?.rootViewController
133
+ else { return nil }
134
+
135
+ var top = root
136
+ while let presented = top.presentedViewController {
137
+ top = presented
138
+ }
139
+ return top
140
+ }
141
+ }
@@ -0,0 +1,268 @@
1
+ import UIKit
2
+
3
+ // MARK: - Delegate (used by PdfViewerViewController)
4
+
5
+ @objc protocol PdfViewerViewDelegate: AnyObject {
6
+ @objc optional func pdfViewerView(_ view: PdfViewerView, didChangeFullScreen isFullScreen: Bool)
7
+ @objc optional func pdfViewerView(_ view: PdfViewerView, didChangeZoom zoomPercentage: Float)
8
+ @objc optional func pdfViewerViewDidRequestDismiss(_ view: PdfViewerView)
9
+ @objc optional func pdfViewerView(_ view: PdfViewerView, didChangeToRightScreen isRight: Bool)
10
+ }
11
+
12
+ // MARK: - PdfViewerView
13
+
14
+ /// Main PDF rendering view.
15
+ ///
16
+ /// Equivalent to ComposeRenderer.kt on Android.
17
+ /// Hosts a UIScrollView with pinch-to-zoom, a vertical stack of PdfPageViews,
18
+ /// and a PdfToolbarView. All page rendering is performed on background threads.
19
+ ///
20
+ /// The @objc block callbacks (`onFullScreenChangeBlock`, etc.) are used by the
21
+ /// Fabric ViewManager (PdfViewerComponentView.mm) to dispatch events to JS.
22
+ /// The delegate protocol is used by PdfViewerViewController for VC-level handling.
23
+ @objc(PdfViewerView)
24
+ final class PdfViewerView: UIView {
25
+
26
+ // MARK: - @objc props (read by Fabric ObjC++ bridge via forward declaration)
27
+
28
+ @objc var pdfUri: String? {
29
+ didSet { guard pdfUri != oldValue else { return }; reloadDocument() }
30
+ }
31
+
32
+ @objc var initialPageIndex: Int = 0 {
33
+ didSet { if document != nil { scrollToPage(initialPageIndex) } }
34
+ }
35
+
36
+ @objc var defaultZoom: CGFloat = PdfConstants.defaultZoom {
37
+ didSet { scrollView.zoomScale = defaultZoom }
38
+ }
39
+
40
+ @objc var isFullScreen: Bool = false {
41
+ didSet {
42
+ toolbar.isHidden = isFullScreen
43
+ toolbarHeightConstraint?.constant = isFullScreen ? 0 : PdfConstants.toolbarHeight
44
+ UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() }
45
+ }
46
+ }
47
+
48
+ @objc var headerText: String? {
49
+ didSet { toolbar.headerText = headerText }
50
+ }
51
+
52
+ @objc var backButtonText: String? {
53
+ didSet { toolbar.backButtonText = backButtonText }
54
+ }
55
+
56
+ // MARK: - @objc block callbacks (wired by Fabric manager and ViewController)
57
+
58
+ @objc var onFullScreenChangeBlock: ((Bool) -> Void)?
59
+ @objc var onZoomChangeBlock: ((Float) -> Void)?
60
+ @objc var onLeftScreenChangeBlock: ((Bool) -> Void)?
61
+ @objc var onRightScreenChangeBlock: ((Bool) -> Void)?
62
+
63
+ // MARK: - Delegate (optional, for ViewController usage)
64
+
65
+ @objc weak var delegate: PdfViewerViewDelegate?
66
+
67
+ // MARK: - Private state
68
+
69
+ private var document: CGPDFDocument?
70
+ private var pageViews: [PdfPageView] = []
71
+
72
+ private let scrollView = UIScrollView()
73
+ private let pageStack = UIStackView()
74
+ private let toolbar = PdfToolbarView()
75
+ private var toolbarHeightConstraint: NSLayoutConstraint?
76
+
77
+ // MARK: - Init
78
+
79
+ override init(frame: CGRect) {
80
+ super.init(frame: frame)
81
+ setupUI()
82
+ }
83
+
84
+ required init?(coder: NSCoder) { fatalError("Use init(frame:)") }
85
+
86
+ // MARK: - Layout
87
+
88
+ override func layoutSubviews() {
89
+ super.layoutSubviews()
90
+ // Rebuild pages when first laid out with a real size
91
+ if !pageViews.isEmpty { return }
92
+ if document != nil { buildPageViews() }
93
+ }
94
+
95
+ // MARK: - UI Setup
96
+
97
+ private func setupUI() {
98
+ backgroundColor = .white
99
+
100
+ // ── Toolbar ──────────────────────────────────────────────────────
101
+ toolbar.delegate = self
102
+ toolbar.translatesAutoresizingMaskIntoConstraints = false
103
+ addSubview(toolbar)
104
+
105
+ let toolbarH = toolbar.heightAnchor.constraint(equalToConstant: PdfConstants.toolbarHeight)
106
+ toolbarHeightConstraint = toolbarH
107
+
108
+ // ── ScrollView ───────────────────────────────────────────────────
109
+ scrollView.delegate = self
110
+ scrollView.minimumZoomScale = PdfConstants.minZoom
111
+ scrollView.maximumZoomScale = PdfConstants.maxZoom
112
+ scrollView.showsVerticalScrollIndicator = true
113
+ scrollView.showsHorizontalScrollIndicator = false
114
+ scrollView.contentInsetAdjustmentBehavior = .never
115
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
116
+ addSubview(scrollView)
117
+
118
+ // ── Page stack ───────────────────────────────────────────────────
119
+ pageStack.axis = .vertical
120
+ pageStack.spacing = PdfConstants.pageSpacing
121
+ pageStack.alignment = .center
122
+ pageStack.translatesAutoresizingMaskIntoConstraints = false
123
+ scrollView.addSubview(pageStack)
124
+
125
+ // ── Constraints ──────────────────────────────────────────────────
126
+ NSLayoutConstraint.activate([
127
+ scrollView.topAnchor.constraint(equalTo: topAnchor),
128
+ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
129
+ scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
130
+ scrollView.bottomAnchor.constraint(equalTo: toolbar.topAnchor),
131
+
132
+ toolbar.leadingAnchor.constraint(equalTo: leadingAnchor),
133
+ toolbar.trailingAnchor.constraint(equalTo: trailingAnchor),
134
+ toolbar.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
135
+ toolbarH,
136
+
137
+ pageStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
138
+ pageStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
139
+ pageStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
140
+ pageStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
141
+ // The stack width is pinned to the scroll view's frame (not content) width,
142
+ // so horizontal overflow is impossible — only vertical scrolling occurs.
143
+ pageStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
144
+ ])
145
+ }
146
+
147
+ // MARK: - Document loading
148
+
149
+ private func reloadDocument() {
150
+ cancelAllPageLoads()
151
+ pageViews.removeAll()
152
+ pageStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
153
+ document = nil
154
+
155
+ guard let uri = pdfUri, !uri.isEmpty else { return }
156
+
157
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
158
+ let doc = PdfRenderingLogic.openDocument(uri: uri)
159
+ DispatchQueue.main.async { [weak self] in
160
+ guard let self, let doc else { return }
161
+ self.document = doc
162
+ // Only build page views once we have a real layout size.
163
+ // layoutSubviews will trigger buildPageViews if bounds == .zero here.
164
+ if self.bounds.width > 0 {
165
+ self.buildPageViews()
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ private func buildPageViews() {
172
+ guard let document else { return }
173
+
174
+ let containerWidth = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width
175
+ let pageWidth = containerWidth - PdfConstants.pageHorizontalPadding * 2
176
+
177
+ for i in 0..<document.numberOfPages {
178
+ let pageView = PdfPageView()
179
+ pageView.pageIndex = i
180
+ let ratio = PdfRenderingLogic.pageAspectRatio(document: document, pageIndex: i)
181
+ let pageHeight = ratio > 0 ? pageWidth / ratio : pageWidth * 1.414
182
+
183
+ pageView.translatesAutoresizingMaskIntoConstraints = false
184
+ pageStack.addArrangedSubview(pageView)
185
+ NSLayoutConstraint.activate([
186
+ pageView.widthAnchor.constraint(equalToConstant: pageWidth),
187
+ pageView.heightAnchor.constraint(equalToConstant: pageHeight),
188
+ ])
189
+ pageViews.append(pageView)
190
+ }
191
+
192
+ renderVisiblePages()
193
+ scrollToPage(initialPageIndex)
194
+ scrollView.zoomScale = defaultZoom
195
+ }
196
+
197
+ // MARK: - Rendering
198
+
199
+ private func renderVisiblePages() {
200
+ guard let document else { return }
201
+ let targetWidth = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width
202
+ pageViews.forEach { $0.load(document: document, targetWidth: targetWidth) }
203
+ }
204
+
205
+ private func cancelAllPageLoads() {
206
+ pageViews.forEach { $0.cancelLoad() }
207
+ }
208
+
209
+ // MARK: - Scroll
210
+
211
+ func scrollToPage(_ index: Int) {
212
+ guard index >= 0, index < pageViews.count else { return }
213
+ let pv = pageViews[index]
214
+ let frame = pv.convert(pv.bounds, to: scrollView)
215
+ scrollView.scrollRectToVisible(frame, animated: false)
216
+ }
217
+ }
218
+
219
+ // MARK: - UIScrollViewDelegate
220
+
221
+ extension PdfViewerView: UIScrollViewDelegate {
222
+
223
+ func viewForZooming(in scrollView: UIScrollView) -> UIView? {
224
+ pageStack
225
+ }
226
+
227
+ func scrollViewDidZoom(_ scrollView: UIScrollView) {
228
+ let pct = Float(scrollView.zoomScale * 100)
229
+ onZoomChangeBlock?(pct)
230
+ delegate?.pdfViewerView?(self, didChangeZoom: pct)
231
+ }
232
+
233
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
234
+ renderVisiblePages()
235
+ }
236
+
237
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
238
+ if !decelerate { renderVisiblePages() }
239
+ }
240
+ }
241
+
242
+ // MARK: - PdfToolbarViewDelegate
243
+
244
+ extension PdfViewerView: PdfToolbarViewDelegate {
245
+
246
+ func toolbarDidTapBack(_ toolbar: PdfToolbarView) {
247
+ onLeftScreenChangeBlock?(true)
248
+ delegate?.pdfViewerViewDidRequestDismiss?(self)
249
+ }
250
+
251
+ func toolbarDidTapZoomIn(_ toolbar: PdfToolbarView) {
252
+ let next = min(scrollView.zoomScale * PdfConstants.zoomStep, PdfConstants.maxZoom)
253
+ scrollView.setZoomScale(next, animated: true)
254
+ }
255
+
256
+ func toolbarDidTapZoomOut(_ toolbar: PdfToolbarView) {
257
+ let next = max(scrollView.zoomScale / PdfConstants.zoomStep, PdfConstants.minZoom)
258
+ scrollView.setZoomScale(next, animated: true)
259
+ }
260
+
261
+ func toolbarDidTapFullScreen(_ toolbar: PdfToolbarView) {
262
+ let next = !isFullScreen
263
+ isFullScreen = next
264
+ toolbar.isFullScreen = next
265
+ onFullScreenChangeBlock?(next)
266
+ delegate?.pdfViewerView?(self, didChangeFullScreen: next)
267
+ }
268
+ }