@reekon-tools/react-native-pdf-canvas 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 (161) hide show
  1. package/PdfCanvas.podspec +80 -0
  2. package/README.md +1391 -0
  3. package/android/build.gradle +175 -0
  4. package/android/consumer-rules.pro +3 -0
  5. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  6. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  7. package/android/gradle.properties +2 -0
  8. package/android/gradlew +248 -0
  9. package/android/gradlew.bat +98 -0
  10. package/android/settings.gradle +34 -0
  11. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +226 -0
  12. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +239 -0
  13. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +251 -0
  14. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +221 -0
  15. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +783 -0
  16. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +1388 -0
  17. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +98 -0
  18. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +324 -0
  19. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +775 -0
  20. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +20 -0
  21. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +558 -0
  22. package/android/src/jsi/cpp/CMakeLists.txt +33 -0
  23. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +469 -0
  24. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +140 -0
  25. package/android/src/main/AndroidManifest.xml +7 -0
  26. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +45 -0
  27. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +564 -0
  28. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +33 -0
  29. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +277 -0
  30. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +214 -0
  31. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +30 -0
  32. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +234 -0
  33. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +47 -0
  34. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +143 -0
  35. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +95 -0
  36. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +626 -0
  37. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasPackage.java +33 -0
  38. package/android/tools/compile-gate.sh +289 -0
  39. package/dist/cache.d.ts +161 -0
  40. package/dist/cache.js +389 -0
  41. package/dist/clock.d.ts +21 -0
  42. package/dist/clock.js +31 -0
  43. package/dist/controller.d.ts +131 -0
  44. package/dist/controller.js +1002 -0
  45. package/dist/geometry.d.ts +96 -0
  46. package/dist/geometry.js +248 -0
  47. package/dist/index.d.ts +52 -0
  48. package/dist/index.js +50 -0
  49. package/dist/index.native.d.ts +21 -0
  50. package/dist/index.native.js +23 -0
  51. package/dist/index.web.d.ts +24 -0
  52. package/dist/index.web.js +26 -0
  53. package/dist/layout.d.ts +78 -0
  54. package/dist/layout.js +152 -0
  55. package/dist/peer-check.d.ts +83 -0
  56. package/dist/peer-check.js +183 -0
  57. package/dist/planner.d.ts +35 -0
  58. package/dist/planner.js +530 -0
  59. package/dist/policy.d.ts +14 -0
  60. package/dist/policy.js +168 -0
  61. package/dist/rasterizer/android.d.ts +121 -0
  62. package/dist/rasterizer/android.js +126 -0
  63. package/dist/rasterizer/buffer.d.ts +18 -0
  64. package/dist/rasterizer/buffer.js +18 -0
  65. package/dist/rasterizer/fake.d.ts +92 -0
  66. package/dist/rasterizer/fake.js +433 -0
  67. package/dist/rasterizer/index.d.ts +38 -0
  68. package/dist/rasterizer/index.js +69 -0
  69. package/dist/rasterizer/index.native.d.ts +19 -0
  70. package/dist/rasterizer/index.native.js +80 -0
  71. package/dist/rasterizer/index.web.d.ts +63 -0
  72. package/dist/rasterizer/index.web.js +75 -0
  73. package/dist/rasterizer/ingest.d.ts +180 -0
  74. package/dist/rasterizer/ingest.js +307 -0
  75. package/dist/rasterizer/ios.d.ts +84 -0
  76. package/dist/rasterizer/ios.js +155 -0
  77. package/dist/rasterizer/native-bridge.d.ts +261 -0
  78. package/dist/rasterizer/native-bridge.js +413 -0
  79. package/dist/rasterizer/web/client.d.ts +84 -0
  80. package/dist/rasterizer/web/client.js +359 -0
  81. package/dist/rasterizer/web/engine.d.ts +69 -0
  82. package/dist/rasterizer/web/engine.js +308 -0
  83. package/dist/rasterizer/web/index.d.ts +26 -0
  84. package/dist/rasterizer/web/index.js +21 -0
  85. package/dist/rasterizer/web/pdfium.d.ts +213 -0
  86. package/dist/rasterizer/web/pdfium.js +170 -0
  87. package/dist/rasterizer/web/protocol.d.ts +138 -0
  88. package/dist/rasterizer/web/protocol.js +37 -0
  89. package/dist/rasterizer/web/session.d.ts +106 -0
  90. package/dist/rasterizer/web/session.js +413 -0
  91. package/dist/rasterizer/web/worker.d.ts +44 -0
  92. package/dist/rasterizer/web/worker.js +76 -0
  93. package/dist/react/PdfContentView.d.ts +98 -0
  94. package/dist/react/PdfContentView.js +189 -0
  95. package/dist/react/index.d.ts +6 -0
  96. package/dist/react/index.js +3 -0
  97. package/dist/react/usePdfDocument.d.ts +134 -0
  98. package/dist/react/usePdfDocument.js +275 -0
  99. package/dist/react/usePdfLayer.d.ts +94 -0
  100. package/dist/react/usePdfLayer.js +388 -0
  101. package/dist/skia/index.d.ts +26 -0
  102. package/dist/skia/index.js +25 -0
  103. package/dist/skia/index.native.d.ts +14 -0
  104. package/dist/skia/index.native.js +16 -0
  105. package/dist/skia/index.web.d.ts +12 -0
  106. package/dist/skia/index.web.js +14 -0
  107. package/dist/skia/resolve.native.d.ts +18 -0
  108. package/dist/skia/resolve.native.js +22 -0
  109. package/dist/skia/resolve.web.d.ts +41 -0
  110. package/dist/skia/resolve.web.js +62 -0
  111. package/dist/skia/skia-api.d.ts +94 -0
  112. package/dist/skia/skia-api.js +113 -0
  113. package/dist/testing/index.d.ts +27 -0
  114. package/dist/testing/index.js +28 -0
  115. package/dist/testing/scenes.d.ts +100 -0
  116. package/dist/testing/scenes.js +267 -0
  117. package/dist/types.d.ts +611 -0
  118. package/dist/types.js +21 -0
  119. package/dist/web-init.d.ts +68 -0
  120. package/dist/web-init.js +64 -0
  121. package/ios/Package.swift +58 -0
  122. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +743 -0
  123. package/ios/Sources/PdfCanvasBridge/include/PdfCanvasModule.h +36 -0
  124. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +38 -0
  125. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +389 -0
  126. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +57 -0
  127. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +44 -0
  128. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +82 -0
  129. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +37 -0
  130. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +158 -0
  131. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +80 -0
  132. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +65 -0
  133. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +548 -0
  134. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +48 -0
  135. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +26 -0
  136. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +122 -0
  137. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +80 -0
  138. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +79 -0
  139. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +129 -0
  140. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +100 -0
  141. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +65 -0
  142. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +111 -0
  143. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +85 -0
  144. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +163 -0
  145. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +297 -0
  146. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +140 -0
  147. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +358 -0
  148. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +262 -0
  149. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +173 -0
  150. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +114 -0
  151. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +158 -0
  152. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +544 -0
  153. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +287 -0
  154. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +396 -0
  155. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +639 -0
  156. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +179 -0
  157. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +283 -0
  158. package/package.json +144 -0
  159. package/react-native.config.js +40 -0
  160. package/scripts/check-skia-peer.mjs +86 -0
  161. package/scripts/skia-peer-check.mjs +363 -0
@@ -0,0 +1,626 @@
1
+ package tools.reekon.pdfcanvas.rn;
2
+
3
+ import android.content.ContentResolver;
4
+ import android.net.Uri;
5
+ import android.os.ParcelFileDescriptor;
6
+ import android.util.Base64;
7
+
8
+ import com.facebook.react.bridge.JavaScriptContextHolder;
9
+ import com.facebook.react.bridge.Promise;
10
+ import com.facebook.react.bridge.ReactApplicationContext;
11
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
12
+ import com.facebook.react.bridge.ReactMethod;
13
+ import com.facebook.react.bridge.ReadableMap;
14
+ import com.facebook.react.bridge.WritableArray;
15
+ import com.facebook.react.bridge.WritableMap;
16
+ import com.facebook.react.bridge.Arguments;
17
+
18
+ import java.io.File;
19
+ import java.io.FileOutputStream;
20
+ import java.io.IOException;
21
+ import java.io.InputStream;
22
+ import java.util.Map;
23
+ import java.util.concurrent.ConcurrentHashMap;
24
+ import java.util.concurrent.ExecutorService;
25
+ import java.util.concurrent.Executors;
26
+ import java.util.concurrent.RejectedExecutionException;
27
+ import java.util.concurrent.atomic.AtomicInteger;
28
+ import java.util.concurrent.atomic.AtomicLong;
29
+
30
+ import tools.reekon.pdfcanvas.jsi.PdfCanvasPixelBridge;
31
+ import tools.reekon.pdfcanvas.PageGeometry;
32
+ import tools.reekon.pdfcanvas.PdfCanvasRasterizer;
33
+ import tools.reekon.pdfcanvas.PdfErrorCode;
34
+ import tools.reekon.pdfcanvas.PdfPlatformQuirks;
35
+ import tools.reekon.pdfcanvas.PdfRasterException;
36
+ import tools.reekon.pdfcanvas.RasterCancellation;
37
+ import tools.reekon.pdfcanvas.RasterPixels;
38
+ import tools.reekon.pdfcanvas.RasterRequest;
39
+
40
+ /**
41
+ * MARSHALLING ONLY.
42
+ *
43
+ * Every line here converts a type, resolves a URI, picks a thread or rejects a
44
+ * promise. Not one of them decides anything about rasterizing — that all lives
45
+ * in {@code tools.reekon.pdfcanvas}, which has no React imports and is proven on
46
+ * device by {@code ./gradlew connectedDebugAndroidTest} with no app in the
47
+ * picture. Keeping the split sharp is the point: this file cannot be tested
48
+ * without building a whole React Native app, so it must not be somewhere
49
+ * behaviour hides.
50
+ *
51
+ * <h3>The transport</h3>
52
+ *
53
+ * TWO OF THEM, chosen at runtime, and the fast one has to earn it.
54
+ *
55
+ * <p>BASE64 is the original and is still the fallback. It is the honest cost of a
56
+ * classic bridge module: neither the legacy bridge nor new-architecture codegen
57
+ * has a binary type. Measured on an LG V60, one 1024x1024 tile costs 19.88 ms to
58
+ * encode here, ~72.6 ms of string marshalling, and <b>1005.46 ms</b> of
59
+ * {@code decodeBase64} in Hermes — 90.6% of the tile, spent undoing an encode
60
+ * that finished a moment earlier.
61
+ *
62
+ * <p>JSI ARRAYBUFFER is the fast one. The raster is written STRAIGHT from the
63
+ * {@code Bitmap} into C++-owned memory on this render's worker thread — see
64
+ * {@link PdfCanvasRasterizer#renderToNativeSlot}, which asks
65
+ * {@link PdfCanvasPixelBridge#nativeStoreFromBitmap} to do it in one memcpy — the
66
+ * promise resolves with a {@code pixelsSlot} number instead of a string, and JS
67
+ * calls the installed {@code __pdfCanvasTakePixels(slot)} host function to
68
+ * receive an ArrayBuffer that WRAPS that memory. No string, no decode, nothing
69
+ * copied on the JS thread, and no {@code byte[]} on the Java heap at all.
70
+ *
71
+ * <p>THE SLOT IS ASKED FOR BEFORE THE RASTER EXISTS, which is what makes that
72
+ * possible: the bitmap is alive only inside the rasterizer, so the decision of
73
+ * where the pixels land has to travel down rather than the pixels travelling up.
74
+ * The consequence is that this file, not the rasterizer, owns every path where a
75
+ * FILLED slot is abandoned — and each one must release it or leak a whole raster
76
+ * of native memory. They are enumerated at {@link #render}.
77
+ *
78
+ * <p>It is selected by {@link #installJsiTransport} + {@link #enableJsiTransport},
79
+ * never assumed: install returns a probe slot, JS takes it and checks the bytes
80
+ * against {@link PdfCanvasPixelBridge#PROBE_BYTES}, and only a JS-side match
81
+ * turns {@link #jsiEnabled} on. A build with no {@code libpdfcanvasjsi.so}, a
82
+ * runtime that refuses the host function, or a Hermes that will not make an
83
+ * ArrayBuffer out of a {@code MutableBuffer} all leave the flag off and every
84
+ * render goes back to base64. Rendering correctly is worth more than rendering
85
+ * fast.
86
+ *
87
+ * <h3>Threading</h3>
88
+ *
89
+ * {@code @ReactMethod} runs on the bridge's native-modules thread, and
90
+ * {@code PdfRenderer.render} is a long synchronous call, so every render is
91
+ * handed to an executor sized to the renderer pool. Rendering on the caller's
92
+ * thread would block every other native module for the length of an ARCH-D
93
+ * raster.
94
+ *
95
+ * EVERY {@code ReadableMap} IS DRAINED ON THE CALLING THREAD, before the
96
+ * executor is handed anything. On the legacy bridge a {@code ReadableNativeMap}
97
+ * is a thin Java shell over C++ memory that is only contractually valid for the
98
+ * duration of the {@code @ReactMethod} call; reading it from a worker after the
99
+ * method has returned is a use-after-free that presents as garbage values or a
100
+ * native crash, and never as an exception you could map to a
101
+ * {@code PdfErrorCode}. So each method marshals to plain Java values first and
102
+ * the lambda closes over those.
103
+ *
104
+ * <h3>UNVERIFIED</h3>
105
+ *
106
+ * Nothing in this file has ever been compiled or run — that needs a build of a
107
+ * consuming app. The three fixes marked {@code UNVERIFIED FIX} below were made
108
+ * by inspection against the bridge's documented contracts, not observed. Treat
109
+ * them, and the rest of this file, as unreviewed code that happens to be
110
+ * type-correct.
111
+ */
112
+ public class PdfCanvasModule extends ReactContextBaseJavaModule {
113
+
114
+ public static final String NAME = "PdfCanvas";
115
+
116
+ /** Mirrors the native pool ceiling; see PdfRendererPool.MAX_POOL_SIZE. */
117
+ private static final int MAX_POOL_SIZE = 4;
118
+
119
+ private final ReactApplicationContext reactContext;
120
+ private final Map<Integer, PdfCanvasRasterizer> handles = new ConcurrentHashMap<>();
121
+ /** Keyed by (handle, token) so a token is only meaningful with its handle. */
122
+ private final Map<Long, RasterCancellation> signals = new ConcurrentHashMap<>();
123
+ private final AtomicInteger nextHandle = new AtomicInteger(1);
124
+ private final ExecutorService renderExecutor =
125
+ Executors.newFixedThreadPool(MAX_POOL_SIZE);
126
+
127
+ /**
128
+ * Slot ids for the JSI transport. Monotonic and deliberately NOT keyed by
129
+ * handle: a slot is written and taken within one render, and reusing ids would
130
+ * let a late take collect a live raster.
131
+ *
132
+ * STATIC, because the map it keys is static. The slots live in one
133
+ * process-wide {@code std::unordered_map} in {@code pdfcanvas-jsi.cpp}, so a
134
+ * per-instance counter starting at 1 would hand the SAME id to two module
135
+ * instances — and RN builds a fresh set of native modules for every reload
136
+ * while the outgoing one may still have rasters parked. The second store then
137
+ * silently replaces the first module's buffer, and that module's take returns
138
+ * ANOTHER tile's pixels: a wrong image, not an error. One counter per process
139
+ * is what makes "taken exactly once" true of the storage rather than of a
140
+ * single module.
141
+ */
142
+ private static final AtomicLong nextSlot = new AtomicLong(1);
143
+
144
+ /**
145
+ * Whether renders return a slot instead of a base64 string.
146
+ *
147
+ * Volatile because it is written on the JS thread (by {@link
148
+ * #enableJsiTransport}, and by a render worker that just watched the native
149
+ * store fail) and read on the bridge thread by every {@link #render}.
150
+ *
151
+ * FALSE UNTIL PROVEN. It is never set by this class deciding the fast path
152
+ * looks available; it is set by JS after a byte-for-byte round trip of {@link
153
+ * PdfCanvasPixelBridge#PROBE_BYTES}.
154
+ */
155
+ private volatile boolean jsiEnabled = false;
156
+
157
+ public PdfCanvasModule(ReactApplicationContext context) {
158
+ super(context);
159
+ this.reactContext = context;
160
+ }
161
+
162
+ @Override
163
+ public String getName() {
164
+ return NAME;
165
+ }
166
+
167
+ @Override
168
+ public void invalidate() {
169
+ // FIRST, before the executor is touched, so no render that has not yet
170
+ // STARTED can ask for a slot.
171
+ //
172
+ // IT NO LONGER DIVERTS A RENDER ALREADY IN FLIGHT, and it used to. The flag
173
+ // was read AFTER the raster came back (the old putPixelsSlot), so clearing
174
+ // it here made an in-flight worker fall back to base64 and strand nothing.
175
+ // render() now reads it BEFORE the draw, because the fill happens inside the
176
+ // rasterizer while the Bitmap is still alive — so a worker already inside
177
+ // PdfRenderer.render (uninterruptible; shutdownNow only sets the flag) holds
178
+ // a slot id already, and will publish its raster after the nativeReleaseAll
179
+ // below has run past. Nothing takes it, and it is freed by the NEXT
180
+ // invalidate() rather than by this one.
181
+ //
182
+ // Bounded — at most one raster per busy lane, dev-only (Fast Refresh), and
183
+ // reclaimed on the following reload. NOT closed by re-reading the flag after
184
+ // the render: negotiateTransport in a SECOND JS runtime calls
185
+ // enableJsiTransport(false) on this same shared flag while the first
186
+ // runtime's renders are in flight, so freeing on a cleared flag would hand
187
+ // that runtime an unredeemable slot instead of a tile. Closing it properly
188
+ // needs a teardown epoch the rasterizer can see, which is not this change.
189
+ jsiEnabled = false;
190
+ // A reload drops the JS side without ever calling close(), so every open
191
+ // document would otherwise keep its file descriptors and parsed pages until
192
+ // the process dies. Fast Refresh makes that happen dozens of times a day.
193
+ for (PdfCanvasRasterizer rasterizer : handles.values()) {
194
+ rasterizer.close();
195
+ }
196
+ handles.clear();
197
+ signals.clear();
198
+ renderExecutor.shutdownNow();
199
+ // A reload also strands any slot whose promise resolved but whose JS
200
+ // continuation never ran. Each one is a whole raster — megabytes — held in
201
+ // C++ with nothing left that could ever take it.
202
+ if (PdfCanvasPixelBridge.isLoaded()) {
203
+ PdfCanvasPixelBridge.nativeReleaseAll();
204
+ }
205
+ super.invalidate();
206
+ }
207
+
208
+ /* ---------------------------------------------------------------- *
209
+ * transport
210
+ * ---------------------------------------------------------------- */
211
+
212
+ /**
213
+ * Installs the JSI host function and parks one probe raster for JS to take.
214
+ *
215
+ * SYNCHRONOUS on purpose, and that is load-bearing twice over. A blocking
216
+ * synchronous {@code @ReactMethod} executes ON THE JS THREAD, which is the
217
+ * only thread where {@code JavaScriptContextHolder.get()} is a live pointer
218
+ * rather than a value that may already have been torn down. It also means the
219
+ * whole probe — install, take, enable — completes before the JS module that
220
+ * called it finishes evaluating, so no render can ever race the decision.
221
+ *
222
+ * <h3>What the probe covers, and what it does not</h3>
223
+ *
224
+ * It round-trips {@link PdfCanvasPixelBridge#PROBE_BYTES} through
225
+ * {@code nativeStore} — the {@code byte[]} store — because a probe needs bytes
226
+ * both sides already agree on and there is no Bitmap at install time. So it
227
+ * proves the JS HALF: the library loaded, the host function is installed,
228
+ * Hermes will build an ArrayBuffer over a {@code MutableBuffer}, and byte
229
+ * values survive the view unmangled.
230
+ *
231
+ * <p>It does NOT exercise the fill every subsequent raster uses.
232
+ * {@code nativeStoreFromBitmap} is a different memcpy from a different source,
233
+ * and its fidelity is proven on device by
234
+ * {@code PdfCanvasJsiTransportTest.directFillBytesEqualTheHeapPathForARealRaster}
235
+ * instead — in the standalone build, with no app around it. Worth knowing when
236
+ * reading a bug report: a passing probe says nothing about the fill.
237
+ *
238
+ * @return the probe slot id, or -1 if the fast path is unavailable. Never
239
+ * throws: every failure here is a fallback, not an error.
240
+ */
241
+ @ReactMethod(isBlockingSynchronousMethod = true)
242
+ public double installJsiTransport() {
243
+ try {
244
+ if (!PdfCanvasPixelBridge.isLoaded()) {
245
+ return -1;
246
+ }
247
+ JavaScriptContextHolder holder = getReactApplicationContext().getJavaScriptContextHolder();
248
+ if (holder == null || holder.get() == 0) {
249
+ return -1;
250
+ }
251
+ if (!PdfCanvasPixelBridge.nativeInstall(holder.get())) {
252
+ return -1;
253
+ }
254
+ long slot = nextSlot.getAndIncrement();
255
+ PdfCanvasPixelBridge.nativeStore(slot, PdfCanvasPixelBridge.PROBE_BYTES);
256
+ return (double) slot;
257
+ } catch (Throwable t) {
258
+ return -1;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Turns the fast transport on, once JS has verified the probe bytes.
264
+ *
265
+ * Synchronous for the same reason as {@link #installJsiTransport}: an async
266
+ * call would be ordered before any render, but "ordered before" is a property
267
+ * of the bridge queue, and this decision is too cheap to make it depend on one.
268
+ *
269
+ * @return the flag as it now stands, so the caller reports what is really live
270
+ * rather than what it asked for.
271
+ */
272
+ @ReactMethod(isBlockingSynchronousMethod = true)
273
+ public boolean enableJsiTransport(boolean enabled) {
274
+ jsiEnabled = enabled && PdfCanvasPixelBridge.isLoaded();
275
+ return jsiEnabled;
276
+ }
277
+
278
+ /* ---------------------------------------------------------------- *
279
+ * open
280
+ * ---------------------------------------------------------------- */
281
+
282
+ @ReactMethod
283
+ public void open(ReadableMap source, int poolSize, Promise promise) {
284
+ // UNVERIFIED FIX (1 of 3): drained HERE, on the bridge thread, not inside
285
+ // the executor lambda. `source` is a ReadableNativeMap whose backing native
286
+ // data is only guaranteed valid for the duration of this call.
287
+ final String password = source.hasKey("password") ? source.getString("password") : null;
288
+ final String base64 = source.hasKey("base64") ? source.getString("base64") : null;
289
+ final String uri = source.hasKey("uri") ? source.getString("uri") : null;
290
+ final int clamped = Math.max(1, Math.min(MAX_POOL_SIZE, poolSize));
291
+
292
+ try {
293
+ renderExecutor.execute(
294
+ () -> {
295
+ PdfCanvasRasterizer rasterizer = null;
296
+ boolean published = false;
297
+ try {
298
+ if (base64 != null) {
299
+ byte[] data = Base64.decode(base64, Base64.DEFAULT);
300
+ rasterizer =
301
+ PdfCanvasRasterizer.openBytes(
302
+ reactContext.getCacheDir(), data, password, clamped);
303
+ } else {
304
+ rasterizer = PdfCanvasRasterizer.openFile(resolve(uri), password, clamped);
305
+ }
306
+
307
+ // UNVERIFIED FIX (2 of 3): everything that can still throw happens
308
+ // BEFORE the rasterizer is published to `handles` and before the
309
+ // promise is resolved. It used to be registered immediately after
310
+ // opening, so a throw in the geometry loop (or an OOM building the
311
+ // result map) rejected the promise while leaving an open rasterizer
312
+ // — file descriptors and parsed pages — that JS had no handle for
313
+ // and could never close. The `published` flag is what makes the
314
+ // finally able to tell "handed over" from "still ours".
315
+ WritableArray pages = Arguments.createArray();
316
+ for (int i = 0; i < rasterizer.pageCount(); i++) {
317
+ PageGeometry g = rasterizer.pageGeometry(i);
318
+ WritableMap page = Arguments.createMap();
319
+ page.putInt("index", g.index);
320
+ page.putInt("width", g.widthPoints);
321
+ page.putInt("height", g.heightPoints);
322
+ page.putInt("rotation", g.rotationDegrees);
323
+ pages.pushMap(page);
324
+ }
325
+
326
+ WritableMap result = Arguments.createMap();
327
+ result.putInt("pageCount", rasterizer.pageCount());
328
+ result.putArray("pages", pages);
329
+ result.putString(
330
+ "rotationSupport",
331
+ PdfCanvasRasterizer.rotationSupport(reactContext.getCacheDir())
332
+ .name()
333
+ .toLowerCase(java.util.Locale.US));
334
+
335
+ int handle = nextHandle.getAndIncrement();
336
+ result.putInt("handle", handle);
337
+ handles.put(handle, rasterizer);
338
+ published = true;
339
+ promise.resolve(result);
340
+ } catch (PdfRasterException e) {
341
+ reject(promise, e);
342
+ } catch (Throwable t) {
343
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, t);
344
+ } finally {
345
+ if (rasterizer != null && !published) {
346
+ rasterizer.close();
347
+ }
348
+ }
349
+ });
350
+ } catch (RejectedExecutionException e) {
351
+ // invalidate() shut the executor down. Without this the task never runs
352
+ // and the promise never settles, which on the JS side is an `await` that
353
+ // hangs for the life of the app rather than an error anyone can see.
354
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, e);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Accepts a plain path, a {@code file://} URI, or a {@code content://} URI.
360
+ *
361
+ * A content URI has no path this process can open, so it is copied into the
362
+ * cache first. That copy is deliberate and unavoidable: {@code PdfRenderer}
363
+ * needs a SEEKABLE descriptor, and a document provider is free to hand back a
364
+ * pipe. It is also why a host that already has a file path should pass one.
365
+ */
366
+ private File resolve(String uri) throws PdfRasterException {
367
+ if (uri == null || uri.isEmpty()) {
368
+ throw new PdfRasterException(PdfErrorCode.NOT_FOUND, "No document uri was given.");
369
+ }
370
+ if (uri.startsWith("content://")) {
371
+ return copyContentUri(Uri.parse(uri));
372
+ }
373
+ if (uri.startsWith("file://")) {
374
+ return new File(Uri.parse(uri).getPath());
375
+ }
376
+ return new File(uri);
377
+ }
378
+
379
+ private File copyContentUri(Uri uri) throws PdfRasterException {
380
+ ContentResolver resolver = reactContext.getContentResolver();
381
+ File out;
382
+ try {
383
+ out = File.createTempFile("pdfcanvas-content-", ".pdf", reactContext.getCacheDir());
384
+ try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "r");
385
+ InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(pfd);
386
+ FileOutputStream fos = new FileOutputStream(out)) {
387
+ byte[] buffer = new byte[64 * 1024];
388
+ int n;
389
+ while ((n = in.read(buffer)) > 0) {
390
+ fos.write(buffer, 0, n);
391
+ }
392
+ }
393
+ } catch (SecurityException e) {
394
+ throw new PdfRasterException(
395
+ PdfErrorCode.NOT_FOUND, "No permission to read " + uri + ".", e);
396
+ } catch (IOException e) {
397
+ throw new PdfRasterException(PdfErrorCode.NOT_FOUND, "Could not read " + uri + ".", e);
398
+ }
399
+ return out;
400
+ }
401
+
402
+ /* ---------------------------------------------------------------- *
403
+ * render
404
+ * ---------------------------------------------------------------- */
405
+
406
+ @ReactMethod
407
+ public void render(int handle, ReadableMap request, int token, Promise promise) {
408
+ PdfCanvasRasterizer rasterizer = handles.get(handle);
409
+ if (rasterizer == null) {
410
+ promise.reject(
411
+ PdfErrorCode.BACKEND_FAILURE.wire(), "No open document for handle " + handle + ".");
412
+ return;
413
+ }
414
+
415
+ // UNVERIFIED FIX (1 of 3, second half): the whole request is turned into a
416
+ // plain Java object HERE, on the bridge thread. Reading `request` inside the
417
+ // executor lambda meant touching a ReadableNativeMap after the
418
+ // @ReactMethod had returned and its native backing was free to go.
419
+ final RasterRequest req;
420
+ try {
421
+ req =
422
+ new RasterRequest(
423
+ request.getInt("page"),
424
+ (float) request.getDouble("x"),
425
+ (float) request.getDouble("y"),
426
+ (float) request.getDouble("width"),
427
+ (float) request.getDouble("height"),
428
+ (float) request.getDouble("scale"),
429
+ !request.hasKey("annotations") || request.getBoolean("annotations"),
430
+ "transparent".equals(request.getString("background"))
431
+ ? RasterRequest.BACKGROUND_TRANSPARENT
432
+ : RasterRequest.BACKGROUND_WHITE);
433
+ } catch (RuntimeException e) {
434
+ // A malformed request map (missing key, wrong type) now fails here rather
435
+ // than on a worker, which is where it belongs: nothing has been allocated
436
+ // and no signal has been registered yet, so there is nothing to unwind.
437
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, e);
438
+ return;
439
+ }
440
+
441
+ long key = signalKey(handle, token);
442
+ // Registered BEFORE the executor runs, and synchronously on the bridge
443
+ // thread. That ordering is what lets cancel() be a pure lookup: bridge calls
444
+ // for one module are executed in the order JS made them, and JS cannot
445
+ // cancel a token before the render() that issued it, so by the time any
446
+ // cancel for this key arrives this entry already exists.
447
+ final RasterCancellation signal = new RasterCancellation();
448
+ signals.put(key, signal);
449
+
450
+ try {
451
+ renderExecutor.execute(
452
+ () -> {
453
+ RasterPixels pixels = null;
454
+ boolean handedOff = false;
455
+ try {
456
+ // Read ONCE. `jsiEnabled` is volatile and invalidate() clears it,
457
+ // so reading it twice could ask for a slot and then decide not to
458
+ // look for one — which is exactly how a filled slot is stranded.
459
+ final boolean fast = jsiEnabled;
460
+ pixels =
461
+ fast
462
+ ? rasterizer.renderToNativeSlot(req, signal, nextSlot.getAndIncrement())
463
+ : rasterizer.render(req, signal);
464
+
465
+ WritableMap result = Arguments.createMap();
466
+ // ONE of the two, never both — `android.ts` branches on which key
467
+ // is present, and sending both would mean paying for the encode
468
+ // this whole path exists to delete.
469
+ //
470
+ // The branch is on WHERE THE PIXELS ARE, not on what was asked
471
+ // for: renderToNativeSlot falls back to the heap whenever the
472
+ // native store refuses, and that tile still has to go out.
473
+ if (pixels.isInNativeSlot()) {
474
+ // A double, because that is what a WritableMap number is on the
475
+ // way to JS. Slots are consumed within one render, so the 2^53
476
+ // exact-integer ceiling is not reachable in any session that
477
+ // could also survive the file descriptors.
478
+ result.putDouble("pixelsSlot", (double) pixels.slot);
479
+ } else {
480
+ result.putString("base64", Base64.encodeToString(pixels.bytes, Base64.NO_WRAP));
481
+ }
482
+ result.putInt("width", pixels.width);
483
+ result.putInt("height", pixels.height);
484
+ // Passed through, never recomputed as width*4 on either side of
485
+ // the bridge. A wrong stride shears the image rather than failing.
486
+ result.putInt("rowBytes", pixels.rowBytes);
487
+ result.putString("format", pixels.format);
488
+ promise.resolve(result);
489
+ // ONLY NOW. Everything that could throw is behind us and the slot
490
+ // id has reached JS, so from here the ArrayBuffer's take is what
491
+ // frees it and this thread must not.
492
+ handedOff = true;
493
+ } catch (PdfRasterException e) {
494
+ reject(promise, e);
495
+ } catch (Throwable t) {
496
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, t);
497
+ } finally {
498
+ // THE ABANDON PATHS, all three of them, and each one leaks a whole
499
+ // raster of native memory without this:
500
+ //
501
+ // 1. Arguments.createMap() or a put* throws (OOM, or a bridge
502
+ // torn down under us) after the slot was filled.
503
+ // 2. promise.resolve throws, or the promise was already settled.
504
+ // 3. NOT the render throwing after it filled the slot: `pixels`
505
+ // is still null on that path, so this cannot release it. The
506
+ // rasterizer owns that one — the post-draw cancellation check
507
+ // releases its own slot before rethrowing.
508
+ //
509
+ // Also NOT covered, and the two cases differ by WHEN the reload
510
+ // lands: a slot id that reaches JS whose continuation never runs
511
+ // is reclaimed by nativeReleaseAll() in invalidate(); a slot
512
+ // published AFTER that nativeReleaseAll — this worker was already
513
+ // mid-draw when the reload started — waits for the NEXT one. See
514
+ // the note at invalidate().
515
+ if (pixels != null && !handedOff) {
516
+ pixels.release();
517
+ }
518
+ signals.remove(key);
519
+ }
520
+ });
521
+ } catch (RejectedExecutionException e) {
522
+ // invalidate() shut the executor down between the handle lookup and here.
523
+ // The task will never run, so its finally will never remove the entry —
524
+ // the same leak the cancel() fix closes, reached from the other side.
525
+ signals.remove(key);
526
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, e);
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Best-effort, and the JS side is told so via
532
+ * {@code capabilities.interruptibleRender: false}. This can stop a queued
533
+ * render starting and can discard a finished one; it cannot interrupt
534
+ * {@code PdfRenderer.render}.
535
+ *
536
+ * UNVERIFIED FIX (3 of 3): a pure LOOKUP. It used to do
537
+ * {@code signals.putIfAbsent(key, cancelled)} when no signal was registered,
538
+ * on the theory that the cancel had beaten its render across the bridge — but
539
+ * "no signal registered" is also, and far more commonly, what a cancel that
540
+ * arrives AFTER its render finished and cleaned up looks like. The two are
541
+ * indistinguishable from here, so every late cancel — and a superseded raster
542
+ * epoch produces a stream of them — planted an entry that nothing ever removed.
543
+ * {@code signals} grew for the life of the module.
544
+ *
545
+ * The speculative branch is not needed anyway: {@link #render} registers its
546
+ * signal synchronously on this same bridge thread before returning, calls from
547
+ * one JS module execute in the order they were made, and a token only exists
548
+ * because a render() issued it. A cancel therefore cannot arrive before the
549
+ * entry it is looking for. If that ordering assumption is ever wrong the cost
550
+ * is one missed cancel on a render that was about to be discarded anyway —
551
+ * which is what "best-effort" already promises — and not unbounded growth.
552
+ */
553
+ @ReactMethod
554
+ public void cancel(int handle, int token) {
555
+ RasterCancellation signal = signals.get(signalKey(handle, token));
556
+ if (signal != null) {
557
+ signal.cancel();
558
+ }
559
+ }
560
+
561
+ @ReactMethod
562
+ public void close(int handle) {
563
+ PdfCanvasRasterizer rasterizer = handles.remove(handle);
564
+ if (rasterizer != null) {
565
+ rasterizer.close();
566
+ }
567
+ }
568
+
569
+ /** Exposed so a host can warn before showing a rotated page wrongly. */
570
+ @ReactMethod
571
+ public void rotationSupport(Promise promise) {
572
+ promise.resolve(
573
+ PdfCanvasRasterizer.rotationSupport(reactContext.getCacheDir())
574
+ .name()
575
+ .toLowerCase(java.util.Locale.US));
576
+ }
577
+
578
+ /** True once this process has hit the API 24 PDFium poisoning. */
579
+ @ReactMethod
580
+ public void isBackendPoisoned(Promise promise) {
581
+ promise.resolve(PdfPlatformQuirks.isBackendPoisoned());
582
+ }
583
+
584
+ /*
585
+ * WHY THERE IS NO LONGER A putPixelsSlot().
586
+ *
587
+ * It used to park a finished {@code byte[]} in a slot AFTER the render
588
+ * returned, which is the only place it could: the module had pixels, not a
589
+ * bitmap. That cost a full-raster ART allocation and a full-raster memcpy per
590
+ * tile, both on the render executor — the serialised bottleneck. The slot is
591
+ * now requested BEFORE the raster is drawn (see {@link #render}) so the fill
592
+ * happens once, straight out of the Bitmap.
593
+ *
594
+ * ONE BEHAVIOUR WENT WITH IT, deliberately. That method latched
595
+ * {@code jsiEnabled = false} on any failure, reasoning that a store which threw
596
+ * once will throw again. The new path does not: a refusal is reported as a
597
+ * heap-backed raster that has ALREADY gone out correctly on base64, so there is
598
+ * nothing to salvage by downgrading the whole session. Latching on a transient
599
+ * allocation failure would trade one slow tile for every subsequent tile being
600
+ * slow — and the base64 tile that prompted it costs ~1 s of Hermes on the
601
+ * measured device, which is a cliff worth not falling off twice. A permanent
602
+ * refusal (a format or stride the native side rejects) costs one cheap failed
603
+ * JNI call per tile, not a copy.
604
+ */
605
+
606
+ /* ---------------------------------------------------------------- */
607
+
608
+ private static long signalKey(int handle, int token) {
609
+ return (((long) handle) << 32) | (token & 0xffffffffL);
610
+ }
611
+
612
+ /**
613
+ * The error CODE is the promise's rejection code, verbatim from
614
+ * {@link PdfErrorCode#wire()} — which is the TypeScript {@code PdfErrorCode}
615
+ * union member. React Native surfaces it as {@code error.code}, and
616
+ * {@code src/rasterizer/android.ts} reconstructs a {@code PdfError} from it
617
+ * with no translation table in between.
618
+ */
619
+ private static void reject(Promise promise, PdfRasterException e) {
620
+ promise.reject(e.code().wire(), e.getMessage(), e);
621
+ }
622
+
623
+ private static void reject(Promise promise, PdfErrorCode code, Throwable t) {
624
+ promise.reject(code.wire(), String.valueOf(t.getMessage()), t);
625
+ }
626
+ }
@@ -0,0 +1,33 @@
1
+ package tools.reekon.pdfcanvas.rn;
2
+
3
+ import com.facebook.react.ReactPackage;
4
+ import com.facebook.react.bridge.NativeModule;
5
+ import com.facebook.react.bridge.ReactApplicationContext;
6
+ import com.facebook.react.uimanager.ViewManager;
7
+
8
+ import java.util.ArrayList;
9
+ import java.util.Collections;
10
+ import java.util.List;
11
+
12
+ /**
13
+ * Autolinking entry point.
14
+ *
15
+ * No view managers: the package owns no canvas. Pages come back as pixels and
16
+ * the HOST draws them inside its own Skia scene — that is the whole premise of
17
+ * `@reekon-tools/react-native-pdf-canvas`, and a native view here would quietly
18
+ * contradict it.
19
+ */
20
+ public class PdfCanvasPackage implements ReactPackage {
21
+
22
+ @Override
23
+ public List<NativeModule> createNativeModules(ReactApplicationContext context) {
24
+ List<NativeModule> modules = new ArrayList<>();
25
+ modules.add(new PdfCanvasModule(context));
26
+ return modules;
27
+ }
28
+
29
+ @Override
30
+ public List<ViewManager> createViewManagers(ReactApplicationContext context) {
31
+ return Collections.emptyList();
32
+ }
33
+ }