@reekon-tools/react-native-pdf-canvas 0.1.0 → 0.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.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +68 -39
  4. package/README.md +213 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/index.native.d.ts +5 -0
  19. package/dist/index.native.js +5 -0
  20. package/dist/index.web.d.ts +10 -0
  21. package/dist/index.web.js +10 -0
  22. package/dist/rasterizer/index.native.d.ts +8 -12
  23. package/dist/rasterizer/index.native.js +15 -21
  24. package/dist/rasterizer/native-bridge.d.ts +51 -75
  25. package/dist/rasterizer/native-bridge.js +49 -33
  26. package/dist/rasterizer/native.d.ts +45 -0
  27. package/dist/rasterizer/native.js +123 -0
  28. package/dist/react/usePdfDocument.d.ts +5 -5
  29. package/dist/react/usePdfLayer.d.ts +10 -1
  30. package/dist/react/usePdfLayer.js +10 -2
  31. package/dist/skia/index.native.d.ts +7 -3
  32. package/dist/skia/index.native.js +7 -3
  33. package/dist/skia/index.web.d.ts +4 -2
  34. package/dist/skia/index.web.js +4 -2
  35. package/dist/testing/scenes.d.ts +3 -3
  36. package/dist/testing/scenes.js +4 -4
  37. package/dist/types.d.ts +36 -42
  38. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  39. package/native/.clangd +5 -0
  40. package/native/CMakeLists.txt +89 -0
  41. package/native/core/include/pdfcanvas/document.h +90 -0
  42. package/native/core/include/pdfcanvas/error.h +47 -0
  43. package/native/core/include/pdfcanvas/jsi.h +18 -0
  44. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  45. package/native/core/include/pdfcanvas/library.h +28 -0
  46. package/native/core/include/pdfcanvas/pixels.h +92 -0
  47. package/native/core/include/pdfcanvas/service.h +87 -0
  48. package/native/core/include/pdfcanvas/slots.h +113 -0
  49. package/native/core/include/pdfcanvas/types.h +110 -0
  50. package/native/core/pdfcanvas-core.cmake +22 -0
  51. package/native/core/src/document.cpp +516 -0
  52. package/native/core/src/error.cpp +29 -0
  53. package/native/core/src/jsi.cpp +66 -0
  54. package/native/core/src/library.cpp +52 -0
  55. package/native/core/src/pixels.cpp +45 -0
  56. package/native/core/src/service.cpp +156 -0
  57. package/native/core/src/slots.cpp +139 -0
  58. package/native/tests/fixtures.cpp +451 -0
  59. package/native/tests/fixtures.h +96 -0
  60. package/native/tests/harness.h +122 -0
  61. package/native/tests/main.cpp +64 -0
  62. package/native/tests/pixels_util.h +126 -0
  63. package/native/tests/test_document.cpp +485 -0
  64. package/native/tests/test_service.cpp +130 -0
  65. package/native/tests/test_slots.cpp +250 -0
  66. package/package.json +38 -8
  67. package/react-native.config.js +5 -2
  68. package/scripts/fetch-pdfium.mjs +487 -0
  69. package/scripts/pdfium-manifest.json +46 -0
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  72. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  73. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  74. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  75. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  76. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  77. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  78. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  79. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  80. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  81. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  82. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  83. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  84. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  85. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  86. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  87. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  88. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  89. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  90. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  91. package/dist/rasterizer/android.d.ts +0 -121
  92. package/dist/rasterizer/android.js +0 -126
  93. package/dist/rasterizer/ios.d.ts +0 -84
  94. package/dist/rasterizer/ios.js +0 -155
  95. package/ios/Package.swift +0 -58
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  98. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  99. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  100. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  101. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  102. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  103. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  104. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  105. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  109. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  110. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  111. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  112. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  113. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  114. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  115. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  116. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  117. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  118. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  119. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  120. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  121. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  122. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  123. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  124. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  125. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  126. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  127. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  128. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  129. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -1,9 +1,7 @@
1
1
  #import "PdfCanvasModule.h"
2
2
 
3
3
  // `<React/RCTBridgeModule.h>` only FORWARD-DECLARES `RCTBridge`, so the category
4
- // below and every `self.bridge` message need the real header. Compiling without it
5
- // fails as "cannot define category for undefined class", which reads like a missing
6
- // pod dependency rather than a missing import.
4
+ // below and every `self.bridge` message need the real header.
7
5
  #import <React/RCTBridge.h>
8
6
  #import <React/RCTLog.h>
9
7
  // `ObjCInteropTurboModule` — a PUBLIC pod header, exposed by React-NativeModulesApple
@@ -14,117 +12,71 @@
14
12
  #import <ReactCommon/RCTTurboModule.h>
15
13
 
16
14
  #import <atomic>
15
+ #import <memory>
16
+ #import <string>
17
+ #import <vector>
17
18
 
18
- #import <PdfCanvasCancellation.h>
19
- #import <PdfCanvasDocumentPool.h>
20
- #import <PdfCanvasErrorCode.h>
21
- #import <PdfCanvasPageGeometry.h>
22
- #import <PdfCanvasRasterPixels.h>
23
- #import <PdfCanvasRasterRequest.h>
24
- #import <PdfCanvasRasterizer.h>
25
-
26
- #import <PdfCanvasSlots.h>
19
+ #include "pdfcanvas/error.h"
20
+ #include "pdfcanvas/jsi.h"
21
+ #include "pdfcanvas/service.h"
22
+ #include "pdfcanvas/slots.h"
27
23
 
28
24
  /**
25
+ * MARSHALLING ONLY, over the C++ core in `native/core`.
26
+ *
27
+ * This file used to sit on a CoreGraphics/PDFKit rasterizer of its own. It now
28
+ * sits on the same PDFium core Android does — same release, same tiles, byte for
29
+ * byte — and the handle map, the (handle, token) cancellation registry and the
30
+ * invalidate ordering it used to carry live in `pdfcanvas::Service`, once, in the
31
+ * language both bindings call. What is left here: NSDictionary in, NSDictionary
32
+ * out, a worker queue, a transport flag, and how a runtime pointer is obtained.
33
+ *
29
34
  * ------------------------------------------------------------------------
30
35
  * HOW THIS MODULE GETS A `jsi::Runtime`, AND THE TWO ROUTES THAT DO NOT WORK
31
36
  * ------------------------------------------------------------------------
32
37
  *
33
- * The JSI transport needs a runtime to install a host function into. There are four
34
- * plausible ways to get one and only two of them work, so both the choice and the
35
- * rejects are recorded here — each reject was tried on a real device.
36
- *
37
38
  * 1. `installJSIBindingsWithRuntime:callInvoker:` — WHAT THIS FILE USES. React
38
39
  * Native calls it with a live `jsi::Runtime &` on the JS thread, at module
39
- * construction, BEFORE JS can call any method on the module. That last part is
40
- * what makes it better than anything the JS side could trigger: the host function
41
- * exists before the first `installJsiTransport()` can look for it, so no render
42
- * can race the decision. It is the same hook `RCTBlobManager` uses.
43
- *
44
- * THE CATCH, and it is why `getTurboModule:` is implemented below:
40
+ * construction, BEFORE JS can call any method on the module. THE CATCH:
45
41
  * `RCTTurboModuleManager` only calls it for a module that responds to
46
- * `getTurboModule:` (`provideTurboModule:`, around line 408 of
47
- * RCTTurboModuleManager.mm). A plain `RCT_EXPORT_MODULE` class goes down
48
- * `provideLegacyModule:` instead and is never asked. So this module returns an
49
- * `ObjCInteropTurboModule` — exactly what the interop layer would have built for
50
- * it — which changes nothing about how its methods are marshalled and everything
51
- * about whether it is offered the runtime.
52
- *
53
- * 2. `self.bridge.runtime` — the FALLBACK, for the old architecture, where
54
- * `getTurboModule:` is never called and `RCTCxxBridge` really does answer.
42
+ * `getTurboModule:`, so this module returns an `ObjCInteropTurboModule` —
43
+ * exactly what the interop layer would have built for it.
55
44
  *
56
- * IT DOES NOT WORK UNDER BRIDGELESS, MEASURED. `RCTInstance` does create an
57
- * `RCTBridgeProxy` with `runtime:_reactInstance->getJavaScriptContext()` and
58
- * `RCTTurboModuleManager` does assign it to a module's `bridge` property — but
59
- * `RCTBridgeProxy` is an `NSProxy` that overrides `methodSignatureForSelector:`
60
- * and `forwardInvocation:` and does NOT override `respondsToSelector:`, so a
61
- * `respondsToSelector:@selector(runtime)` guard answers NO for a method the proxy
62
- * genuinely implements. On device this produced exactly one line —
63
- * "[PdfCanvas] No bridge runtime is reachable" — and a session that rendered
64
- * every tile through base64. Dropping the guard and messaging it anyway would
65
- * work, and is not what this does: a bare message to an `NSProxy` whose
66
- * `forwardInvocation:` logs an error and returns garbage is not a thing to do on
67
- * the strength of a reading of one version's source.
45
+ * 2. `self.bridge.runtime` — the FALLBACK, for the old architecture. It does NOT
46
+ * work under bridgeless (measured): `RCTBridgeProxy` is an `NSProxy` that does
47
+ * not override `respondsToSelector:`, so the guard answers NO.
68
48
  *
69
- * (`@shopify/react-native-skia`'s `SkiaManager.mm` uses this route with an
70
- * `if (bridge.runtime)` guard. That is not evidence it works under bridgeless —
71
- * on the new architecture skia installs through its own codegen'd spec and the
72
- * bridge path is its legacy branch.)
49
+ * 3. `RCTCallInvokerModule` + `invokeSync` — REJECTED; it deadlocks from a
50
+ * blocking synchronous method (`executeSynchronouslyOnSameThread_CAN_DEADLOCK`).
73
51
  *
74
- * 3. `RCTCallInvokerModule` + `callInvoker->invokeSync` — REJECTED, and it would
75
- * DEADLOCK. `RuntimeSchedulerCallInvoker::invokeSync` calls
76
- * `RuntimeScheduler_Modern::executeNowOnTheSameThread`, whose re-entrancy guard is
77
- * a `thread_local` pointer set only INSIDE itself. A blocking synchronous native
78
- * method runs on the JS thread but not inside that function, so the guard is null
79
- * and it falls through to `executeSynchronouslyOnSameThread_CAN_DEADLOCK`, which
80
- * schedules onto the JS thread and waits — while being the JS thread.
81
- *
82
- * 4. `callInvoker->invokeAsync` at construction — REJECTED on ordering. It would be
83
- * safe, but the lambda runs after the current JS turn, and
84
- * `NativeModules.PdfCanvas` is touched and `installJsiTransport()` called within
85
- * one turn. The probe would find nothing and every session would fall back.
52
+ * 4. `invokeAsync` at construction — REJECTED on ordering: `installJsiTransport()`
53
+ * is called within the same JS turn and would find nothing.
86
54
  */
87
55
  @interface RCTBridge (PdfCanvasRuntime)
88
56
  - (void *)runtime;
89
57
  @end
90
58
 
91
- /**
92
- * Mirrors `kPdfCanvasMaxPoolSize`, and is the width of the render queue.
93
- *
94
- * The queue is sized to the ceiling rather than to a document's pool because one
95
- * module serves every open document. A render whose document has fewer lanes blocks
96
- * on that document's own semaphore, which is where the real limit lives.
97
- */
98
- static const NSInteger kPdfCanvasQueueWidth = 4;
99
-
100
59
  @implementation PdfCanvasModule {
101
- /** JS handle -> rasterizer. Read on the queue, written on the bridge thread. */
102
- NSMutableDictionary<NSNumber *, PdfCanvasRasterizer *> *_handles;
103
- /** (handle, token) -> signal, so a token is only meaningful with its handle. */
104
- NSMutableDictionary<NSNumber *, PdfCanvasCancellation *> *_signals;
105
- /** Guards both dictionaries. */
106
- NSLock *_lock;
60
+ /**
61
+ * SHARED with every queued block. A block that is mid-render when a reload
62
+ * lands keeps the service alive until it returns, so a torn-down module can
63
+ * never free memory a worker is still writing into.
64
+ */
65
+ std::shared_ptr<pdfcanvas::Service> _service;
66
+ /**
67
+ * ONE lane. PDFium is not thread-safe and the core serialises every call
68
+ * behind a process-wide lock, so a second worker would only queue behind the
69
+ * first while holding a promise open. (CoreGraphics parallelised; PDFium does
70
+ * not, and the iOS default was already one.)
71
+ */
107
72
  NSOperationQueue *_renderQueue;
108
- NSInteger _nextHandle;
109
73
  /**
110
- * Whether renders return a slot instead of a base64 string.
111
- *
112
- * FALSE UNTIL PROVEN. It is never set by this class deciding the fast path looks
113
- * available; it is set by JS after a byte-for-byte round trip of
114
- * `PdfCanvasSlotsProbeBytes`. Atomic because it is written on the JS thread (by
115
- * `enableJsiTransport`, and by `invalidate`) and read on a render worker.
74
+ * Whether renders return a slot instead of a base64 string. FALSE UNTIL
75
+ * PROVEN by the JS-side probe round trip. Atomic: written on the JS thread,
76
+ * read on the worker.
116
77
  */
117
78
  std::atomic<bool> _jsiEnabled;
118
79
  std::atomic<bool> _invalidated;
119
- /**
120
- * Set once by `installJSIBindingsWithRuntime:callInvoker:`, which is the only
121
- * thing that ever sees a live runtime on the new architecture.
122
- *
123
- * A flag rather than the pointer: the host function is installed there and then,
124
- * so all `installJsiTransport` needs to know is whether that already happened.
125
- * Keeping a `jsi::Runtime *` around would be a pointer with no owner and no way to
126
- * learn it had been torn down.
127
- */
128
80
  BOOL _hostFunctionInstalled;
129
81
  }
130
82
 
@@ -133,26 +85,17 @@ static const NSInteger kPdfCanvasQueueWidth = 4;
133
85
  RCT_EXPORT_MODULE(PdfCanvas)
134
86
 
135
87
  + (BOOL)requiresMainQueueSetup {
136
- // Nothing here touches UIKit at construction. Returning YES would move module
137
- // setup to the main thread for no reason and log a warning about it.
138
88
  return NO;
139
89
  }
140
90
 
141
91
  - (instancetype)init {
142
92
  self = [super init];
143
93
  if (self != nil) {
144
- _handles = [NSMutableDictionary dictionary];
145
- _signals = [NSMutableDictionary dictionary];
146
- _lock = [[NSLock alloc] init];
147
- _nextHandle = 1;
94
+ _service = std::make_shared<pdfcanvas::Service>();
148
95
  _jsiEnabled = false;
149
96
  _invalidated = false;
150
97
  _renderQueue = [[NSOperationQueue alloc] init];
151
- // WIDTH-LIMITED, mirroring Android's `Executors.newFixedThreadPool`. An
152
- // unbounded concurrent `dispatch_queue` would spawn a thread per queued render
153
- // — and every render beyond a document's pool size does nothing but block on
154
- // that pool's semaphore, so those threads would be pure cost.
155
- _renderQueue.maxConcurrentOperationCount = kPdfCanvasQueueWidth;
98
+ _renderQueue.maxConcurrentOperationCount = 1;
156
99
  _renderQueue.name = @"tools.reekon.pdfcanvas.render";
157
100
  _renderQueue.qualityOfService = NSQualityOfServiceUserInitiated;
158
101
  }
@@ -160,53 +103,51 @@ RCT_EXPORT_MODULE(PdfCanvas)
160
103
  }
161
104
 
162
105
  /**
163
- * A reload drops the JS side without ever calling `close`, so every open document
164
- * would otherwise keep its parsed pages until the process dies. Fast Refresh makes
165
- * that happen dozens of times a day.
106
+ * A reload drops the JS side without ever calling `close`. The service closes
107
+ * every document (waiting for an in-flight render to reach its next
108
+ * cancellation checkpoint), cancels every registered signal and drains every
109
+ * parked slot — rasters whose promise resolved but whose JS continuation never
110
+ * ran.
111
+ *
112
+ * THE QUEUE IS DELIBERATELY NOT CANCELLED. A cancelled `NSOperation` never runs
113
+ * its block, so its resolve/reject pair is never called and the JS `await` hangs
114
+ * for the life of the app. Letting queued operations run costs nothing: each
115
+ * fails fast with `backend-failure` against an invalidated service.
166
116
  */
167
117
  - (void)invalidate {
168
- // FIRST, before anything else, so no render that has not yet STARTED can ask for
169
- // a slot.
170
- //
171
- // IT DOES NOT DIVERT A RENDER ALREADY IN FLIGHT, and that is the same bounded
172
- // leak Android documents: a worker already inside `CGContextDrawPDFPage` holds a
173
- // sink, and it will publish its raster after the `PdfCanvasSlotsReleaseAll`
174
- // below has run past. Nothing takes it, and it is freed by the NEXT invalidate.
175
- // At most one raster per busy lane, dev-only, reclaimed on the following reload.
176
118
  _jsiEnabled = false;
177
119
  _invalidated = true;
120
+ _service->invalidate();
121
+ }
178
122
 
179
- [_lock lock];
180
- NSArray<PdfCanvasRasterizer *> *open = _handles.allValues;
181
- [_handles removeAllObjects];
182
- [_signals removeAllObjects];
183
- [_lock unlock];
184
-
185
- for (PdfCanvasRasterizer *rasterizer in open) {
186
- // Non-blocking even with a render in flight — a lease owns a CoreFoundation
187
- // reference to its document, so this cannot free one out from under a worker.
188
- // See `PdfCanvasDocumentPool`.
189
- [rasterizer close];
190
- }
123
+ /* ---------------------------------------------------------------- *
124
+ * helpers
125
+ * ---------------------------------------------------------------- */
191
126
 
192
- // THE QUEUE IS DELIBERATELY NOT CANCELLED. `cancelAllOperations` looks like the
193
- // tidy thing to do and it strands promises: an `NSOperation` cancelled before it
194
- // starts never runs its block, so the `resolve`/`reject` pair inside it is never
195
- // called, and on the JS side that is an `await` that hangs for the life of the app
196
- // rather than an error anyone can see.
197
- //
198
- // Letting the queued operations run costs nothing and settles every one of them.
199
- // Each holds a strong reference to its rasterizer, which the loop above has just
200
- // closed, so it fails fast with `backend-failure` — and a render that has already
201
- // STARTED was never cancellable anyway, because `CGContextDrawPDFPage` is
202
- // uninterruptible once entered. (Android reaches the same conclusion from the other
203
- // side: its binding catches `RejectedExecutionException` for tasks submitted after
204
- // `shutdownNow`, precisely so those promises settle.)
205
-
206
- // A reload also strands any slot whose promise resolved but whose JS
207
- // continuation never ran. Each one is a whole raster — megabytes — held in C++
208
- // with nothing left that could ever take it.
209
- PdfCanvasSlotsReleaseAll();
127
+ static NSString *PdfCanvasResolvePath(NSString *uri);
128
+
129
+ static NSString *WireCode(pdfcanvas::ErrorCode code) {
130
+ return [NSString stringWithUTF8String:pdfcanvas::wireName(code)];
131
+ }
132
+
133
+ static NSError *ErrorFor(pdfcanvas::ErrorCode code, NSString *message) {
134
+ return [NSError errorWithDomain:@"tools.reekon.pdfcanvas"
135
+ code:static_cast<NSInteger>(code)
136
+ userInfo:@{NSLocalizedDescriptionKey : message ?: @"PDF Canvas failed."}];
137
+ }
138
+
139
+ /**
140
+ * The error CODE is the promise's rejection code, verbatim from
141
+ * `pdfcanvas::wireName` — the TypeScript `PdfErrorCode` union member. React Native
142
+ * surfaces it as `error.code`, and `native-bridge.ts` reconstructs a `PdfError`
143
+ * from it with no translation table in between.
144
+ */
145
+ static void RejectWith(RCTPromiseRejectBlock reject, pdfcanvas::ErrorCode code, NSString *message) {
146
+ reject(WireCode(code), message, ErrorFor(code, message));
147
+ }
148
+
149
+ static void RejectWithPdfError(RCTPromiseRejectBlock reject, const pdfcanvas::PdfError &error) {
150
+ RejectWith(reject, error.code(), [NSString stringWithUTF8String:error.message().c_str()]);
210
151
  }
211
152
 
212
153
  /* ---------------------------------------------------------------- *
@@ -216,246 +157,154 @@ RCT_EXPORT_MODULE(PdfCanvas)
216
157
  /**
217
158
  * Installs the JSI host function and parks one probe raster for JS to take.
218
159
  *
219
- * SYNCHRONOUS on purpose, and that is load-bearing twice over. MEASURED by reading
220
- * RN 0.86.2's own source: `ObjCTurboModule::performMethodInvocation` runs a sync
221
- * method through `nativeMethodCallInvoker_->invokeSync`, and
222
- * `ModuleNativeMethodCallInvoker::invokeSync` is `work()` — called inline, on the
223
- * calling thread, which for a JS-initiated call is the JS thread. That is the only
224
- * thread where `bridge.runtime` is a live pointer rather than a value that may
225
- * already have been torn down. It also means the whole probe — install, take,
226
- * enable — completes before the JS module that called it finishes evaluating, so no
227
- * render can race the decision.
228
- *
229
- * IT RETURNS AN OBJECT, AND MUST. Same measurement: for a non-void, non-promise
230
- * return the interop layer does `void *rawResult; [inv getReturnValue:&rawResult];
231
- * result = (__bridge id)rawResult;`. A method declared to return `double` or `BOOL`
232
- * would have its bits reinterpreted as an object pointer. `RCT_EXPORT_BLOCKING_
233
- * SYNCHRONOUS_METHOD` defaults the return type to `id` for exactly this reason;
234
- * the `NSNumber` below is that default honoured rather than fought.
160
+ * SYNCHRONOUS on purpose: a blocking synchronous method runs inline on the JS
161
+ * thread — the only thread where `bridge.runtime` is a live pointer — and
162
+ * completes before the JS module that called it finishes evaluating, so no render
163
+ * can race the decision. IT RETURNS AN OBJECT, AND MUST: the interop layer reads a
164
+ * non-void, non-promise return as `id`, and a `double` would be reinterpreted as
165
+ * a pointer.
235
166
  *
236
- * @return the probe slot id, or -1 if the fast path is unavailable. Never throws:
237
- * every failure here is a fallback, not an error.
167
+ * @return the probe slot id, or -1 if the fast path is unavailable. Never throws.
238
168
  */
239
169
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(installJsiTransport) {
240
170
  @try {
241
171
  if (_invalidated) {
242
172
  return @(-1);
243
173
  }
244
- // NORMALLY ALREADY DONE. On the new architecture
245
- // `installJSIBindingsWithRuntime:callInvoker:` ran at construction, so all this
246
- // method has to do is park a probe raster. The branch below is the old
247
- // architecture, where that hook is never called and `RCTCxxBridge` genuinely
248
- // does answer `-runtime`.
174
+ // NORMALLY ALREADY DONE by installJSIBindingsWithRuntime: on the new
175
+ // architecture. The branch below is the old architecture, where that hook is
176
+ // never called and `RCTCxxBridge` genuinely does answer `-runtime`.
249
177
  if (!_hostFunctionInstalled) {
250
178
  RCTBridge *bridge = self.bridge;
251
- void *runtime =
252
- [bridge respondsToSelector:@selector(runtime)] ? bridge.runtime : NULL;
179
+ void *runtime = [bridge respondsToSelector:@selector(runtime)] ? bridge.runtime : NULL;
253
180
  if (runtime == NULL) {
254
- RCTLogWarn(
255
- @"[PdfCanvas] No JS runtime was reachable — neither "
256
- @"installJSIBindingsWithRuntime: nor bridge.runtime — so every tile "
257
- @"will cross the bridge as base64. That is correct but roughly 85x "
258
- @"slower per tile. bridge=%@",
259
- bridge == nil ? @"nil" : NSStringFromClass([bridge class]));
181
+ RCTLogWarn(@"[PdfCanvas] No JS runtime was reachable, so every tile will cross the "
182
+ @"bridge as base64. That is correct but roughly 85x slower per tile.");
260
183
  return @(-1);
261
184
  }
262
- _hostFunctionInstalled = PdfCanvasSlotsInstall(runtime);
185
+ _hostFunctionInstalled = pdfcanvas::installTakePixels(runtime);
263
186
  if (!_hostFunctionInstalled) {
264
187
  return @(-1);
265
188
  }
266
189
  }
267
- return @(PdfCanvasSlotsStoreProbe());
190
+ return @(pdfcanvas::slots::storeProbe());
268
191
  } @catch (NSException *exception) {
269
- // Includes a `respondsToSelector:` on an `NSProxy` that declines to answer it.
270
- // Every failure here is a fallback, not an error.
271
192
  return @(-1);
272
193
  }
273
194
  }
274
195
 
275
- /**
276
- * Turns the fast transport on, once JS has verified the probe bytes.
277
- *
278
- * Synchronous for the same reason as `installJsiTransport`: an async call would be
279
- * ordered before any render, but "ordered before" is a property of the bridge queue
280
- * and this decision is too cheap to make it depend on one.
281
- *
282
- * @return the flag as it now stands, so the caller reports what is really live
283
- * rather than what it asked for.
284
- */
285
- RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(enableJsiTransport:(BOOL)enabled) {
196
+ /** Turns the fast transport on, once JS has verified the probe bytes. */
197
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(enableJsiTransport : (BOOL)enabled) {
286
198
  const bool live = enabled && !_invalidated;
287
199
  _jsiEnabled = live;
288
200
  return @(live);
289
201
  }
290
202
 
203
+ /** Slots currently parked, for a host checking that the transport does not leak. */
204
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(pendingRasterSlots) {
205
+ return @(static_cast<NSInteger>(pdfcanvas::slots::count()));
206
+ }
207
+
291
208
  /* ---------------------------------------------------------------- *
292
209
  * open
293
210
  * ---------------------------------------------------------------- */
294
211
 
295
212
  RCT_EXPORT_METHOD(open
296
- : (NSDictionary *)source poolSize
297
- : (double)poolSize resolve
213
+ : (NSDictionary *)source resolve
298
214
  : (RCTPromiseResolveBlock)resolve reject
299
215
  : (RCTPromiseRejectBlock)reject) {
300
- // DRAINED HERE, on the calling thread, before the queue is handed anything. On
301
- // iOS a `NSDictionary` from the bridge is a real Foundation object rather than a
302
- // shell over C++ memory — unlike Android's `ReadableNativeMap`, which is only
303
- // contractually valid for the duration of the method — so this is defensive
304
- // rather than required. It is still the right shape: it keeps the two bindings
305
- // reading the same way, and it means a malformed request fails before a worker
306
- // has allocated anything.
216
+ // DRAINED HERE, on the calling thread, before the queue is handed anything —
217
+ // defensive on iOS (an NSDictionary from the bridge is a real object), and the
218
+ // same shape as the Android binding.
307
219
  NSString *uri = [source[@"uri"] isKindOfClass:NSString.class] ? source[@"uri"] : nil;
308
- NSString *base64 =
309
- [source[@"base64"] isKindOfClass:NSString.class] ? source[@"base64"] : nil;
310
- NSString *password =
311
- [source[@"password"] isKindOfClass:NSString.class] ? source[@"password"] : nil;
312
- const NSInteger lanes = [self clampPoolSize:poolSize];
220
+ NSString *base64 = [source[@"base64"] isKindOfClass:NSString.class] ? source[@"base64"] : nil;
221
+ NSString *password = [source[@"password"] isKindOfClass:NSString.class] ? source[@"password"] : nil;
313
222
 
314
223
  if (_invalidated) {
315
- [self reject:reject code:PdfCanvasErrorCodeBackendFailure
316
- message:@"The PdfCanvas module has been invalidated."
317
- underlying:nil];
224
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure, @"The PdfCanvas module has been invalidated.");
318
225
  return;
319
226
  }
320
227
 
321
- __weak PdfCanvasModule *weakSelf = self;
228
+ std::shared_ptr<pdfcanvas::Service> service = _service;
322
229
  [_renderQueue addOperationWithBlock:^{
323
- PdfCanvasModule *strongSelf = weakSelf;
324
- if (strongSelf == nil) {
325
- return;
230
+ pdfcanvas::Source src;
231
+ if (password != nil) {
232
+ src.hasPassword = true;
233
+ src.password = password.UTF8String;
326
234
  }
327
- NSError *error = nil;
328
- PdfCanvasRasterizer *rasterizer = nil;
329
235
  if (base64 != nil) {
330
- NSData *data = [[NSData alloc] initWithBase64EncodedString:base64
331
- options:0];
236
+ NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:0];
332
237
  if (data == nil) {
333
- [strongSelf reject:reject
334
- code:PdfCanvasErrorCodeCorrupt
335
- message:@"The document's base64 payload could not be decoded."
336
- underlying:nil];
238
+ RejectWith(reject, pdfcanvas::ErrorCode::Corrupt,
239
+ @"The document's base64 payload could not be decoded.");
337
240
  return;
338
241
  }
339
- rasterizer = [PdfCanvasRasterizer openData:data
340
- password:password
341
- poolSize:lanes
342
- error:&error];
242
+ const auto *bytes = static_cast<const uint8_t *>(data.bytes);
243
+ src.bytes.assign(bytes, bytes + data.length);
343
244
  } else {
344
- NSURL *url = [strongSelf resolveURI:uri error:&error];
345
- if (url != nil) {
346
- rasterizer = [PdfCanvasRasterizer openFileURL:url
347
- password:password
348
- poolSize:lanes
349
- error:&error];
350
- }
351
- }
352
- if (rasterizer == nil) {
353
- [strongSelf rejectWithError:reject error:error];
354
- return;
355
- }
356
-
357
- // EVERYTHING THAT CAN STILL FAIL HAPPENS BEFORE THE RASTERIZER IS PUBLISHED.
358
- // Registering it first and then throwing while building the result would leave
359
- // an open document — parsed pages, a mapped file — that JS has no handle for
360
- // and could never close. Android's binding carries the same ordering and the
361
- // same `published` flag for the same reason.
362
- NSMutableArray<NSDictionary *> *pages =
363
- [NSMutableArray arrayWithCapacity:(NSUInteger)rasterizer.pageCount];
364
- for (NSInteger i = 0; i < rasterizer.pageCount; i++) {
365
- PdfCanvasPageGeometry *geometry = [rasterizer pageGeometryAtIndex:i
366
- error:&error];
367
- if (geometry == nil) {
368
- [rasterizer close];
369
- [strongSelf rejectWithError:reject error:error];
245
+ NSString *path = PdfCanvasResolvePath(uri);
246
+ if (path == nil) {
247
+ RejectWith(reject, pdfcanvas::ErrorCode::NotFound,
248
+ [NSString stringWithFormat:@"Could not resolve %@ to a file path.", uri ?: @"(nil)"]);
370
249
  return;
371
250
  }
372
- [pages addObject:@{
373
- @"index" : @(geometry.index),
374
- // DOUBLES, not ints. A PDF point is not an integer — A4 is 595.276 wide —
375
- // and Android reports ints only because `PdfRenderer` gives it ints.
376
- // `PageGeometry.width` in `src/types.ts` is a `number`, so nothing has to
377
- // change on the JS side to carry the extra precision.
378
- @"width" : @(geometry.widthPoints),
379
- @"height" : @(geometry.heightPoints),
380
- @"rotation" : @(geometry.rotationDegrees),
381
- }];
251
+ src.path = path.fileSystemRepresentation;
382
252
  }
383
253
 
384
- NSNumber *handle = nil;
385
- [strongSelf->_lock lock];
386
- if (strongSelf->_invalidated) {
387
- [strongSelf->_lock unlock];
388
- [rasterizer close];
389
- [strongSelf reject:reject
390
- code:PdfCanvasErrorCodeBackendFailure
391
- message:@"The PdfCanvas module was invalidated while opening."
392
- underlying:nil];
393
- return;
254
+ try {
255
+ const pdfcanvas::Service::Opened opened = service->open(src);
256
+ NSMutableArray<NSDictionary *> *pages = [NSMutableArray arrayWithCapacity:opened.pages.size()];
257
+ for (const auto &page : opened.pages) {
258
+ [pages addObject:@{
259
+ @"index" : @(page.index),
260
+ // DOUBLES: a PDF point is not an integer — A4 is 595.276 wide.
261
+ @"width" : @(page.width),
262
+ @"height" : @(page.height),
263
+ @"rotation" : @(page.rotation),
264
+ }];
265
+ }
266
+ resolve(@{
267
+ @"handle" : @(opened.handle),
268
+ @"pageCount" : @(static_cast<NSInteger>(opened.pages.size())),
269
+ @"pages" : pages,
270
+ });
271
+ } catch (const pdfcanvas::PdfError &error) {
272
+ RejectWithPdfError(reject, error);
273
+ } catch (const std::exception &error) {
274
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure,
275
+ [NSString stringWithUTF8String:error.what()]);
394
276
  }
395
- handle = @(strongSelf->_nextHandle++);
396
- strongSelf->_handles[handle] = rasterizer;
397
- [strongSelf->_lock unlock];
398
-
399
- resolve(@{
400
- @"handle" : handle,
401
- @"pageCount" : @(rasterizer.pageCount),
402
- @"pages" : pages,
403
- // A CONSTANT on this platform, and part of the shared wire format rather
404
- // than an Android extension: a host should not have to know which platform
405
- // it is on to ask whether a `/Rotate` page will draw upright.
406
- @"rotationSupport" : PdfCanvasRasterizer.rotationSupport,
407
- });
408
277
  }];
409
278
  }
410
279
 
411
280
  /**
412
- * Accepts a plain path or a `file://` URL.
281
+ * Accepts a plain path or a `file://` URL and returns a file-system path.
413
282
  *
414
- * NO `content://` EQUIVALENT TO HANDLE, which is the one place this is simpler than
415
- * Android: there, a document-provider URI has no path the process can open and has
416
- * to be copied into the cache first, because `PdfRenderer` needs a seekable
417
- * descriptor and a provider may hand back a pipe. On iOS a picked document arrives
418
- * as a `file://` URL that is either already in the app's container (the usual case,
419
- * when the host asked for a copy) or security-scoped — and a security-scoped URL is
420
- * the HOST's to keep open, which `PdfCanvasPreflight` says in its unreadable-file
421
- * message rather than guessing at here.
283
+ * NO `content://` EQUIVALENT TO HANDLE, unlike Android. On iOS a picked document
284
+ * arrives as a `file://` URL that is either already in the app's container or
285
+ * security-scoped — and a security-scoped URL is the HOST's to keep open.
422
286
  */
423
- - (nullable NSURL *)resolveURI:(nullable NSString *)uri
424
- error:(NSError *_Nullable *_Nullable)error {
287
+ static NSString *PdfCanvasResolvePath(NSString *uri) {
425
288
  if (uri.length == 0) {
426
- PdfCanvasFail(error, PdfCanvasErrorCodeNotFound, @"No document uri was given.",
427
- nil);
428
289
  return nil;
429
290
  }
430
291
  if ([uri hasPrefix:@"file://"]) {
431
292
  NSURL *url = [NSURL URLWithString:uri];
432
293
  if (url == nil || url.path.length == 0) {
433
- // A path with characters that need percent-encoding — a space, a `#` — makes
434
- // `URLWithString:` return nil. Falling back to treating the whole string as a
435
- // path is wrong (it would keep the scheme), so the encoded form is tried.
436
- NSString *encoded = [uri
437
- stringByAddingPercentEncodingWithAllowedCharacters:
438
- NSCharacterSet.URLPathAllowedCharacterSet];
294
+ // A path with characters that need percent-encoding — a space, a `#` —
295
+ // makes `URLWithString:` return nil; try the encoded form.
296
+ NSString *encoded =
297
+ [uri stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
439
298
  url = encoded == nil ? nil : [NSURL URLWithString:encoded];
440
299
  }
441
- if (url == nil || url.path.length == 0) {
442
- PdfCanvasFail(
443
- error, PdfCanvasErrorCodeNotFound,
444
- [NSString stringWithFormat:@"Could not parse %@ as a file URL.", uri],
445
- nil);
446
- return nil;
447
- }
448
- return [NSURL fileURLWithPath:url.path];
300
+ return url.path.length > 0 ? url.path : nil;
449
301
  }
450
302
  if ([uri containsString:@"://"]) {
451
- // Handed on as-is so preflight can produce the "this backend does no
452
- // networking" message, which is more useful than a parse failure here.
453
- NSURL *url = [NSURL URLWithString:uri];
454
- if (url != nil) {
455
- return url;
456
- }
303
+ // Not a file. The core's preflight reports it as not-found with the path in
304
+ // the message, which is more useful than a parse failure here.
305
+ return uri;
457
306
  }
458
- return [NSURL fileURLWithPath:uri];
307
+ return uri;
459
308
  }
460
309
 
461
310
  /* ---------------------------------------------------------------- *
@@ -468,227 +317,95 @@ RCT_EXPORT_METHOD(render
468
317
  : (double)token resolve
469
318
  : (RCTPromiseResolveBlock)resolve reject
470
319
  : (RCTPromiseRejectBlock)reject) {
471
- NSNumber *handleKey = @((NSInteger)handle);
472
- [_lock lock];
473
- PdfCanvasRasterizer *rasterizer = _handles[handleKey];
474
- [_lock unlock];
475
- if (rasterizer == nil) {
476
- [self reject:reject
477
- code:PdfCanvasErrorCodeBackendFailure
478
- message:[NSString stringWithFormat:@"No open document for handle %ld.",
479
- (long)handle]
480
- underlying:nil];
481
- return;
482
- }
483
-
484
- PdfCanvasRasterRequest *req = [[PdfCanvasRasterRequest alloc]
485
- initWithPage:[request[@"page"] integerValue]
486
- docX:[request[@"x"] doubleValue]
487
- docY:[request[@"y"] doubleValue]
488
- docWidth:[request[@"width"] doubleValue]
489
- docHeight:[request[@"height"] doubleValue]
490
- scale:[request[@"scale"] doubleValue]
491
- annotations:request[@"annotations"] == nil
492
- ? YES
493
- : [request[@"annotations"] boolValue]
494
- background:[@"transparent" isEqual:request[@"background"]]
495
- ? PdfCanvasBackgroundTransparent
496
- : PdfCanvasBackgroundWhite];
497
-
498
- NSNumber *signalKey = [self signalKeyForHandle:(NSInteger)handle
499
- token:(NSInteger)token];
500
- // Registered BEFORE the operation runs, and synchronously on the calling thread.
501
- // That ordering is what lets `cancel` be a pure lookup: calls from one JS module
502
- // are executed in the order JS made them, and JS cannot cancel a token before
503
- // the render that issued it, so by the time any cancel for this key arrives this
504
- // entry already exists.
505
- PdfCanvasCancellation *signal = [[PdfCanvasCancellation alloc] init];
506
- [_lock lock];
507
- _signals[signalKey] = signal;
508
- [_lock unlock];
509
-
510
- __weak PdfCanvasModule *weakSelf = self;
320
+ pdfcanvas::RasterRequest req;
321
+ req.page = [request[@"page"] intValue];
322
+ req.x = [request[@"x"] doubleValue];
323
+ req.y = [request[@"y"] doubleValue];
324
+ req.width = [request[@"width"] doubleValue];
325
+ req.height = [request[@"height"] doubleValue];
326
+ req.scale = [request[@"scale"] doubleValue];
327
+ req.annotations = request[@"annotations"] == nil ? true : [request[@"annotations"] boolValue];
328
+ req.background = [@"transparent" isEqual:request[@"background"]] ? pdfcanvas::Background::Transparent
329
+ : pdfcanvas::Background::White;
330
+ const int nativeHandle = static_cast<int>(handle);
331
+ const int64_t nativeToken = static_cast<int64_t>(token);
332
+
333
+ // Registered BEFORE the operation runs, synchronously on the calling thread.
334
+ // That ordering is what lets `cancel` be a pure lookup: JS cannot cancel a token
335
+ // before the render that issued it, so by the time any cancel for this token
336
+ // arrives the signal already exists.
337
+ _service->registerCancellation(nativeHandle, nativeToken);
338
+
339
+ std::shared_ptr<pdfcanvas::Service> service = _service;
340
+ // Read ONCE, here: `invalidate` clears it, and a worker that read it twice could
341
+ // ask for a slot and then decide not to look for one.
342
+ const bool fast = _jsiEnabled;
511
343
  [_renderQueue addOperationWithBlock:^{
512
- PdfCanvasModule *strongSelf = weakSelf;
513
- if (strongSelf == nil) {
514
- return;
515
- }
516
- PdfCanvasRasterPixels *pixels = nil;
344
+ int64_t slot = 0;
517
345
  BOOL handedOff = NO;
518
346
  @try {
519
- // Read ONCE. The flag is atomic and `invalidate` clears it, so reading it
520
- // twice could ask for a slot and then decide not to look for one — which is
521
- // exactly how a filled slot is stranded.
522
- const bool fast = strongSelf->_jsiEnabled;
523
- id<PdfCanvasPixelSink> sink =
524
- fast ? [[PdfCanvasSlotSink alloc] init] : nil;
525
-
526
- NSError *error = nil;
527
- pixels = [rasterizer render:req cancellation:signal sink:sink error:&error];
528
- if (pixels == nil) {
529
- [strongSelf rejectWithError:reject error:error];
347
+ pdfcanvas::RasterPixels pixels;
348
+ try {
349
+ pixels = service->render(nativeHandle, req, nativeToken, fast);
350
+ } catch (const pdfcanvas::PdfError &error) {
351
+ RejectWithPdfError(reject, error);
352
+ return;
353
+ } catch (const std::exception &error) {
354
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure,
355
+ [NSString stringWithUTF8String:error.what()]);
530
356
  return;
531
357
  }
358
+ slot = pixels.slot;
532
359
 
533
360
  NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:5];
534
361
  // ONE of the two, never both — `native-bridge.ts` branches on which key is
535
- // present, and sending both would mean paying for the encode this whole path
536
- // exists to delete.
537
- //
538
- // The branch is on WHERE THE PIXELS ARE, not on what was asked for: a sink
539
- // may refuse its reservation, and that tile still has to go out.
540
- if (pixels.isInNativeSlot) {
541
- // A double, because that is what a bridge number is on the way to JS.
542
- // Slots are consumed within one render, so the 2^53 exact-integer ceiling
543
- // is not reachable in any session that could also survive the file
544
- // descriptors.
362
+ // present. The branch is on WHERE THE PIXELS ARE, not on what was asked for:
363
+ // the store may have refused and the tile still has to go out.
364
+ if (pixels.inSlot()) {
545
365
  result[@"pixelsSlot"] = @((double)pixels.slot);
546
366
  } else {
547
- result[@"base64"] = [pixels.bytes base64EncodedStringWithOptions:0];
367
+ NSData *data = [NSData dataWithBytesNoCopy:pixels.bytes.data()
368
+ length:pixels.bytes.size()
369
+ freeWhenDone:NO];
370
+ result[@"base64"] = [data base64EncodedStringWithOptions:0];
548
371
  }
549
372
  result[@"width"] = @(pixels.width);
550
373
  result[@"height"] = @(pixels.height);
551
374
  // Passed through, never recomputed as width*4 on either side of the bridge.
552
- // A wrong stride shears the image rather than failing.
553
375
  result[@"rowBytes"] = @(pixels.rowBytes);
554
- result[@"format"] = pixels.format;
376
+ result[@"format"] = @"rgba8888";
555
377
  resolve(result);
556
- // ONLY NOW. Everything that could fail is behind us and the slot id has
557
- // reached JS, so from here the ArrayBuffer's take is what frees it and this
558
- // thread must not.
378
+ // ONLY NOW: the slot id has reached JS, and the ArrayBuffer's take is what
379
+ // frees it from here on.
559
380
  handedOff = YES;
560
381
  } @catch (NSException *exception) {
561
- [strongSelf reject:reject
562
- code:PdfCanvasErrorCodeBackendFailure
563
- message:[NSString stringWithFormat:@"%@: %@", exception.name,
564
- exception.reason]
565
- underlying:nil];
382
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure,
383
+ [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]);
566
384
  } @finally {
567
- // THE ABANDON PATHS, and each one strands a whole raster of C++ memory
568
- // without this:
569
- //
570
- // 1. Building the result dictionary throws (memory pressure), or
571
- // `resolve` throws, or the promise was already settled.
572
- // 2. NOT the render failing after it filled the slot: `pixels` is nil on
573
- // that path, so this cannot release it. The rasterizer owns that one —
574
- // its post-draw cancellation check abandons its own sink before
575
- // returning.
576
- //
577
- // Also not covered, and the two differ by WHEN the reload lands: a slot id
578
- // that reaches JS whose continuation never runs is reclaimed by
579
- // `PdfCanvasSlotsReleaseAll` in `invalidate`; a slot published AFTER that —
580
- // this worker was already mid-draw when the reload started — waits for the
581
- // NEXT one. See the note at `invalidate`.
582
- if (pixels != nil && !handedOff) {
583
- [pixels releaseSlot];
385
+ // THE ABANDON PATH: a slot was filled and then the result could not be built
386
+ // or the promise could not be settled. Without this a whole raster of native
387
+ // memory sits in the store until the next invalidate.
388
+ if (slot != 0 && !handedOff) {
389
+ pdfcanvas::slots::release(slot);
584
390
  }
585
- [strongSelf->_lock lock];
586
- [strongSelf->_signals removeObjectForKey:signalKey];
587
- [strongSelf->_lock unlock];
588
391
  }
589
392
  }];
590
393
  }
591
394
 
592
395
  /**
593
- * Best-effort, and the JS side is told so via `capabilities.interruptibleRender:
594
- * false`. This can stop a queued render starting and can discard a finished one; it
595
- * cannot interrupt `CGContextDrawPDFPage`.
596
- *
597
- * A PURE LOOKUP, and deliberately not a speculative insert. "No signal registered"
598
- * is far more commonly what a cancel that arrives AFTER its render finished and
599
- * cleaned up looks like than a cancel that beat its render across the bridge — and
600
- * a superseded raster epoch produces a stream of the former. Planting an entry for
601
- * each would grow the dictionary for the life of the module. Android's binding
602
- * carries the same fix and the same note.
396
+ * Genuinely mid-render now, not best-effort: the core polls the signal from
397
+ * PDFium's progressive renderer between batches of page objects, so a superseded
398
+ * tile stops drawing within one batch. A pure lookup — a cancel for a token whose
399
+ * render already finished finds nothing and does nothing.
603
400
  */
604
401
  RCT_EXPORT_METHOD(cancel : (double)handle token : (double)token) {
605
- NSNumber *key = [self signalKeyForHandle:(NSInteger)handle
606
- token:(NSInteger)token];
607
- [_lock lock];
608
- PdfCanvasCancellation *signal = _signals[key];
609
- [_lock unlock];
610
- [signal cancel];
402
+ _service->cancel(static_cast<int>(handle), static_cast<int64_t>(token));
611
403
  }
612
404
 
613
405
  RCT_EXPORT_METHOD(close : (double)handle) {
614
- NSNumber *key = @((NSInteger)handle);
615
- [_lock lock];
616
- PdfCanvasRasterizer *rasterizer = _handles[key];
617
- [_handles removeObjectForKey:key];
618
- [_lock unlock];
619
- // Non-blocking even with a render in flight. See `PdfCanvasDocumentPool`.
620
- [rasterizer close];
621
- }
622
-
623
- /** Exposed for wire parity with Android, where the answer is a device probe. */
624
- RCT_EXPORT_METHOD(rotationSupport
625
- : (RCTPromiseResolveBlock)resolve reject
626
- : (RCTPromiseRejectBlock)reject) {
627
- resolve(PdfCanvasRasterizer.rotationSupport);
628
- }
629
-
630
- /**
631
- * Slots currently parked, for a host that wants to see the transport is not
632
- * leaking. Diagnostic only — nothing in the package reads it.
633
- */
634
- RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(pendingRasterSlots) {
635
- return @(PdfCanvasSlotsCount());
636
- }
637
-
638
- /*
639
- * WHY THERE IS NO `isBackendPoisoned`.
640
- *
641
- * Android exposes one because a `PdfRenderer` constructor that throws permanently
642
- * poisons PDFium for the whole process on API 24, and a host may want to know it
643
- * has happened. CoreGraphics has no equivalent behaviour — MEASURED: a `%PDF-`
644
- * header over garbage returns NULL and a subsequent open of a good document
645
- * succeeds, repeatedly, in the same process (`FailureTests.swift`,
646
- * `testHeaderOnlyGarbageIsCorruptAndPoisonsNothing`). A method that always answered
647
- * "no" would be a method a host might one day trust to mean something.
648
- */
649
-
650
- /* ---------------------------------------------------------------- *
651
- * helpers
652
- * ---------------------------------------------------------------- */
653
-
654
- - (NSInteger)clampPoolSize:(double)requested {
655
- if (!(requested >= 1)) {
656
- return 1;
657
- }
658
- const NSInteger size = (NSInteger)requested;
659
- return size > kPdfCanvasQueueWidth ? kPdfCanvasQueueWidth : size;
660
- }
661
-
662
- - (NSNumber *)signalKeyForHandle:(NSInteger)handle token:(NSInteger)token {
663
- return @(((int64_t)handle << 32) | (token & 0xffffffffLL));
664
- }
665
-
666
- /**
667
- * The error CODE is the promise's rejection code, verbatim from
668
- * `PdfCanvasErrorCodeWire` — which is the TypeScript `PdfErrorCode` union member.
669
- * React Native surfaces it as `error.code`, and
670
- * `src/rasterizer/native-bridge.ts` reconstructs a `PdfError` from it with no
671
- * translation table in between.
672
- */
673
- - (void)reject:(RCTPromiseRejectBlock)reject
674
- code:(PdfCanvasErrorCode)code
675
- message:(NSString *)message
676
- underlying:(nullable NSError *)underlying {
677
- reject(PdfCanvasErrorCodeWire(code), message,
678
- underlying ?: PdfCanvasMakeError(code, message, nil));
679
- }
680
-
681
- - (void)rejectWithError:(RCTPromiseRejectBlock)reject
682
- error:(nullable NSError *)error {
683
- if (error == nil) {
684
- [self reject:reject
685
- code:PdfCanvasErrorCodeBackendFailure
686
- message:@"The rasterizer failed without reporting an error."
687
- underlying:nil];
688
- return;
689
- }
690
- reject(PdfCanvasErrorCodeWire(PdfCanvasErrorCodeOf(error)),
691
- error.localizedDescription, error);
406
+ // Blocks only if a render on this document is in flight, and then only until
407
+ // that render reaches its next cancellation checkpoint.
408
+ _service->close(static_cast<int>(handle));
692
409
  }
693
410
 
694
411
  /* ---------------------------------------------------------------- *
@@ -696,23 +413,12 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(pendingRasterSlots) {
696
413
  * ---------------------------------------------------------------- */
697
414
 
698
415
  /**
699
- * Returns the SAME wrapper the interop layer would have built for this module.
700
- *
701
- * `ObjCInteropTurboModule` is what `RCTTurboModuleManager provideLegacyModule:`
702
- * constructs for any `RCT_EXPORT_MODULE` class, and it reads this class's exported
703
- * methods off the `__rct_export__` metadata at runtime — so returning it changes
704
- * NOTHING about how `open`, `render`, `cancel`, `close` or the two synchronous
705
- * methods are marshalled. What it changes is which branch of the manager this module
706
- * takes: responding to `getTurboModule:` moves it from `provideLegacyModule:` to
707
- * `provideTurboModule:`, and only the latter offers
708
- * `installJSIBindingsWithRuntime:callInvoker:`.
709
- *
710
- * NO CODEGEN, deliberately. A generated spec would mean a `codegenConfig` in
711
- * `package.json`, a spec file, and a JS side that reaches the module through
712
- * `TurboModuleRegistry` instead of `NativeModules` — and `NativeModules` is what
713
- * works on BOTH architectures and what `src/rasterizer/index.native.ts` does for
714
- * Android too. Paying for codegen to obtain a hook the interop wrapper can be handed
715
- * directly is the wrong trade.
416
+ * Returns the SAME wrapper the interop layer would have built for this module, so
417
+ * the methods above are marshalled exactly as a plain `RCT_EXPORT_MODULE` class's
418
+ * are — but `RCTTurboModuleManager` routes the module through `provideTurboModule:`,
419
+ * the only branch that offers `installJSIBindingsWithRuntime:callInvoker:`.
420
+ * NO CODEGEN, deliberately: `NativeModules` is what works on BOTH architectures
421
+ * and what `src/rasterizer/index.native.ts` does for Android too.
716
422
  */
717
423
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
718
424
  (const facebook::react::ObjCTurboModule::InitParams &)params {
@@ -720,23 +426,15 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(pendingRasterSlots) {
720
426
  }
721
427
 
722
428
  /**
723
- * The one place a live `jsi::Runtime` is handed to this module.
724
- *
725
- * Called on the JS thread at module construction, before JS can call any method
726
- * here — so the host function exists before the first `installJsiTransport()` looks
727
- * for it, and no render can race the decision. See the file header for the three
728
- * routes that do not work.
429
+ * The one place a live `jsi::Runtime` is handed to this module: called on the JS
430
+ * thread at module construction, before JS can call any method here.
729
431
  */
730
432
  - (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime
731
- callInvoker:
732
- (const std::shared_ptr<facebook::react::CallInvoker> &)
733
- callInvoker {
734
- _hostFunctionInstalled = PdfCanvasSlotsInstall(&runtime);
433
+ callInvoker:(const std::shared_ptr<facebook::react::CallInvoker> &)callInvoker {
434
+ _hostFunctionInstalled = pdfcanvas::installTakePixels(&runtime);
735
435
  if (!_hostFunctionInstalled) {
736
- RCTLogWarn(
737
- @"[PdfCanvas] The JS runtime refused __pdfCanvasTakePixels, so every "
738
- @"tile will cross the bridge as base64. That is correct but roughly 85x "
739
- @"slower per tile.");
436
+ RCTLogWarn(@"[PdfCanvas] The JS runtime refused __pdfCanvasTakePixels, so every tile will "
437
+ @"cross the bridge as base64. That is correct but roughly 85x slower per tile.");
740
438
  }
741
439
  }
742
440