@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,413 @@
1
+ /**
2
+ * The worker's own logic, with `self` factored out.
3
+ *
4
+ * `./worker.ts` is nothing but the four lines that bind this to a real
5
+ * `DedicatedWorkerGlobalScope`; everything a bug could live in — the queue, the
6
+ * cancellation window, the fetch, the error mapping, the open-before-render
7
+ * ordering — is here, takes its dependencies as arguments, and runs in vitest
8
+ * under plain node against the REAL PDFium module.
9
+ *
10
+ * ------------------------------------------------------------------------
11
+ * THE YIELD IS THE CANCELLATION MECHANISM
12
+ * ------------------------------------------------------------------------
13
+ *
14
+ * A worker processes messages one at a time and `FPDF_RenderPageBitmap` is
15
+ * synchronous, so nothing can interrupt a render that has started. What CAN be
16
+ * stopped is a render that has not started — and only if the `cancel` message
17
+ * gets read first. Handling each `render` message by rendering inside the
18
+ * handler would make that impossible: messages are FIFO, so three queued renders
19
+ * would all complete before a `cancel` posted after them was ever looked at.
20
+ *
21
+ * So renders are QUEUED here and drained one per macrotask, with a yield between
22
+ * them. That yield is the only window in which a `cancel` can land, and it is
23
+ * exactly what `RasterizerCapabilities.interruptibleRender: false` means in
24
+ * practice: cancellation happens between requests, never inside one.
25
+ *
26
+ * See `macrotaskYield` below for what that yield is actually made of, and why it
27
+ * is not the `setTimeout(0)` it used to be.
28
+ */
29
+ import { openPdfiumDocument } from './engine.js';
30
+ import { PdfError } from '../../types.js';
31
+ /**
32
+ * One macrotask turn, the plain way. THE FALLBACK, and half of the default.
33
+ *
34
+ * Correct, and slow the moment it repeats: Chrome clamps a timer whose NESTING
35
+ * LEVEL exceeds 5 to a 4 ms floor, and a chain of these only ever nests deeper,
36
+ * because each one is scheduled from inside the task the previous one's timer
37
+ * started. Measured in real headless Chrome inside a real `{type: 'module'}`
38
+ * worker (harness `/c/tmp/mc/web`, 24 consecutive yields): the first six are
39
+ * free (0.10 0.00 0.00 0.00 0.00 0.00 ms) and every one after costs 4.0-6.2 ms
40
+ * — 90.0 ms total, mean 3.75 ms. `drain` yields once per tile, so a 4-10 tile
41
+ * detail epoch burnt ~0-20 ms of pure worker idle and a 32-piece base grid
42
+ * ~130 ms.
43
+ */
44
+ const timerYield = () => new Promise(resolve => {
45
+ setTimeout(resolve, 0);
46
+ });
47
+ /**
48
+ * ONE channel for the whole module, built on first use and never torn down.
49
+ *
50
+ * `undefined` = not tried yet; `null` = this runtime has no usable
51
+ * `MessageChannel`, so `macrotaskYield` is timer-only. A fresh channel per yield
52
+ * would allocate two ports per tile, which is a cost of exactly the kind this
53
+ * change exists to remove, so it is built once.
54
+ */
55
+ let sharedChannel;
56
+ function openYieldChannel() {
57
+ const Ctor = globalThis.MessageChannel;
58
+ // ABSENT ONLY IN OLD OR EXOTIC RUNTIMES. `MessageChannel` is a global in every
59
+ // browser, in `DedicatedWorkerGlobalScope`, and in Node >= 15 (re-exported
60
+ // from `worker_threads`) — measured `typeof MessageChannel === 'function'` on
61
+ // node v24.14.0, which is what vitest runs here. It is NOT in React Native's
62
+ // Hermes global scope, but nothing on the native path reaches this file:
63
+ // `session.ts` is web-only, behind the `browser`/`default` export condition.
64
+ // So the fallback is for a runtime nobody has named, not for a platform we
65
+ // ship to — and it is a fallback rather than a throw because a missing yield
66
+ // would break rendering, not just slow it down.
67
+ if (typeof Ctor !== 'function')
68
+ return null;
69
+ try {
70
+ const { port1, port2 } = new Ctor();
71
+ const waiters = [];
72
+ port1.onmessage = () => {
73
+ // FIFO, and `wake` posts exactly one message per waiter, so message N
74
+ // wakes waiter N. Concurrent yields therefore cannot cross-resolve: they
75
+ // are matched by POSITION, not by identity. (`drain` is single-flight per
76
+ // session, so the case that makes this reachable at all is two sessions in
77
+ // one worker — the channel is per MODULE, not per session.) Even a
78
+ // mismatch would still be a correct yield, since every waiter is asking
79
+ // for the same thing and nothing is carried across, but matching by
80
+ // position means the question never has to be argued.
81
+ waiters.shift()?.();
82
+ // NODE ONLY, AND LOAD-BEARING. A `MessagePort` with a live `onmessage`
83
+ // refs the libuv loop, so an always-armed module-scope channel keeps the
84
+ // process alive forever — measured: `node` with nothing open but an armed
85
+ // port never exits (killed by a timeout at exit 124). Unref-ing
86
+ // permanently is the opposite failure: the loop then drains and exits with
87
+ // a yield still outstanding, stranding `drain` — also measured, the
88
+ // posted message is simply never delivered. So the port is ref'd exactly
89
+ // while a yield is in flight and unref'd the moment none is.
90
+ if (waiters.length === 0)
91
+ port1.unref?.();
92
+ };
93
+ port1.unref?.();
94
+ return {
95
+ waiters,
96
+ wake: () => {
97
+ port1.ref?.();
98
+ port2.postMessage(0);
99
+ },
100
+ };
101
+ }
102
+ catch {
103
+ // A runtime that has the constructor but refuses to build one. Nothing is
104
+ // known to do this; falling back still beats throwing out of a render.
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * THE CANCEL WINDOW: a port hop, and then a timer turn.
110
+ *
111
+ * WHY A PORT HOP. It is a macrotask that no clamp applies to. Same Chrome
112
+ * worker, same 24 consecutive yields as `timerYield`'s 90.0 ms: 0.2 ms total,
113
+ * mean 0.008 ms.
114
+ *
115
+ * WHY A TIMER TURN AS WELL. Because a port hop ALONE does not drain the timer
116
+ * queue, and the timer queue is where the `cancel` is. Measured on node v24.14.0
117
+ * with K `setTimeout(0)` callbacks already pending when the yield starts, 20
118
+ * trials at each K in {1, 2, 4, 8, 16, 32}: a timer yield runs ALL K of them
119
+ * before it resolves, in every trial; a bare port hop runs ZERO of them, in
120
+ * every trial. A port-only yield would have narrowed the very window this file
121
+ * exists to keep open, and would have done it quietly — the abort test in
122
+ * `web-rasterizer.test.ts` delivers its cancels over a `setTimeout`-backed
123
+ * channel, so a bare hop turns that test flaky rather than loudly wrong.
124
+ *
125
+ * AND THE TIMER TURN IS FREE, which is the whole reason this shape works: the
126
+ * hop RESETS the nesting level Chrome clamps on, because the timer is always
127
+ * scheduled from a port-message task and never from another timer task.
128
+ * Measured in the same worker over the same 24 yields: 0.1-0.3 ms total, mean
129
+ * 0.004-0.013 ms — indistinguishable from the bare hop, and ~280x cheaper than
130
+ * the bare timer it replaces.
131
+ *
132
+ * So this is STRICTLY STRONGER than what it replaces — a hop AND a full timer
133
+ * turn, where there was only a timer turn — at 1/280th the cost, and its worst
134
+ * case (a runtime that clamps the following timer anyway) is exactly the
135
+ * behaviour that shipped before it.
136
+ *
137
+ * Exported for `web-rasterizer.test.ts`, which asserts the drained-timer
138
+ * property above directly. Still not public: nothing in the package's `exports`
139
+ * map reaches `session.ts` except `./worker`, which re-exports nothing.
140
+ */
141
+ export const macrotaskYield = async () => {
142
+ if (sharedChannel === undefined)
143
+ sharedChannel = openYieldChannel();
144
+ const channel = sharedChannel;
145
+ if (channel !== null) {
146
+ await new Promise(resolve => {
147
+ channel.waiters.push(resolve);
148
+ channel.wake();
149
+ });
150
+ }
151
+ await timerYield();
152
+ };
153
+ const resolveFetch = (injected) => {
154
+ if (injected !== undefined)
155
+ return injected;
156
+ const found = globalThis.fetch;
157
+ if (typeof found !== 'function') {
158
+ throw new PdfError('unsupported', 'This runtime has no global `fetch`, so a document given as a `uri` ' +
159
+ 'cannot be loaded. Pass the document as `{data}` instead.');
160
+ }
161
+ return found;
162
+ };
163
+ /**
164
+ * Anything thrown, as the two fields the wire carries.
165
+ *
166
+ * A `PdfError` keeps its code — that is the whole point of the union being
167
+ * frozen and shared. Anything else is a bug or a WASM abort, and becomes
168
+ * `backend-failure` with its message preserved, because a swallowed message here
169
+ * is a support ticket that says only "rendering failed".
170
+ */
171
+ function describe(error) {
172
+ if (error instanceof PdfError) {
173
+ return { code: error.code, message: error.message };
174
+ }
175
+ const message = error?.message ?? String(error);
176
+ return { code: 'backend-failure', message: String(message) };
177
+ }
178
+ export function createWorkerSession(deps) {
179
+ const yieldToEventLoop = deps.yieldToEventLoop ?? macrotaskYield;
180
+ let opening = null;
181
+ let document = null;
182
+ let disposed = false;
183
+ const queue = [];
184
+ /**
185
+ * Ids the main thread has given up on.
186
+ *
187
+ * A SET AND NOT A FLAG ON THE JOB, because a `cancel` can arrive before its
188
+ * `render` does — the main thread aborts on its own clock and both messages
189
+ * are just messages. An entry is consumed when the matching job is dequeued;
190
+ * one that never matches is dropped when the session is disposed.
191
+ */
192
+ const cancelled = new Set();
193
+ /**
194
+ * The job `drain` is currently awaiting, or null.
195
+ *
196
+ * Exists so `cancel` can tell a job that is STILL CANCELLABLE (queued, or in
197
+ * flight across the `requireDocument` await) from one that has already been
198
+ * answered. Without it every cancel for an already-finished render left a
199
+ * permanent entry in `cancelled`: the deletes all live on the dequeue path,
200
+ * so an id that never gets dequeued again is never removed, and a long-lived
201
+ * document's set grows with every abort a gesture produces.
202
+ */
203
+ let inFlight = null;
204
+ let draining = false;
205
+ const postError = (id, error) => {
206
+ const { code, message } = describe(error);
207
+ deps.post({ kind: 'error', id, code, message });
208
+ };
209
+ async function readSource(request) {
210
+ if ('bytes' in request.source) {
211
+ return new Uint8Array(request.source.bytes);
212
+ }
213
+ const url = request.source.url;
214
+ let response;
215
+ try {
216
+ response = await resolveFetch(deps.fetch)(url);
217
+ }
218
+ catch (error) {
219
+ // A network failure, a CORS refusal or a bad scheme. All indistinguishable
220
+ // from JS by design, so the message carries the URL and nothing pretends
221
+ // to know which it was.
222
+ throw new PdfError('backend-failure', `Could not fetch the document from ${url}: ` +
223
+ `${error?.message ?? String(error)}.`, { cause: error });
224
+ }
225
+ if (!response.ok) {
226
+ throw new PdfError(
227
+ // 404 and 410 are the two that mean the document is not there. An
228
+ // expired Firebase download token is a 403, which is a real failure but
229
+ // not a missing document, so it stays `backend-failure` and says so.
230
+ response.status === 404 || response.status === 410
231
+ ? 'not-found'
232
+ : 'backend-failure', `Fetching the document from ${url} returned ` +
233
+ `${response.status} ${response.statusText}.`);
234
+ }
235
+ return new Uint8Array(await response.arrayBuffer());
236
+ }
237
+ async function open(request) {
238
+ // Both awaits before any PDFium call, and in this order for a reason worth
239
+ // keeping: the wasm compile and the document fetch are independent, so they
240
+ // run concurrently rather than one after the other.
241
+ const [binding, bytes] = await Promise.all([
242
+ deps.loadPdfium(request.wasm),
243
+ readSource(request),
244
+ ]);
245
+ if (disposed) {
246
+ throw new PdfError('cancelled', 'The session was closed while opening.');
247
+ }
248
+ return openPdfiumDocument(binding, bytes, request.password);
249
+ }
250
+ async function requireDocument() {
251
+ if (opening === null) {
252
+ throw new PdfError('backend-failure', 'A render arrived before the document was opened.');
253
+ }
254
+ return opening;
255
+ }
256
+ async function drain() {
257
+ if (draining)
258
+ return;
259
+ draining = true;
260
+ try {
261
+ while (queue.length > 0 && !disposed) {
262
+ const job = queue.shift();
263
+ if (job === undefined)
264
+ break;
265
+ if (cancelled.delete(job.id))
266
+ continue;
267
+ inFlight = job.id;
268
+ // `finally`, not a trailing assignment: the body below leaves by
269
+ // `continue`, by `return` and by throwing, and an `inFlight` left
270
+ // pointing at a finished job would let a late cancel be recorded for an
271
+ // id nothing can consume — the same unbounded growth from the other end.
272
+ try {
273
+ let target;
274
+ try {
275
+ target = await requireDocument();
276
+ }
277
+ catch (error) {
278
+ postError(job.id, error);
279
+ continue;
280
+ }
281
+ // Re-checked after the await: both of these can become true while the
282
+ // open is in flight, and rendering into a disposed session would be a
283
+ // use-after-free rather than a wasted tile.
284
+ if (disposed)
285
+ return;
286
+ if (cancelled.delete(job.id))
287
+ continue;
288
+ try {
289
+ const raster = target.render(job.request);
290
+ const buffer = raster.bytes.buffer;
291
+ deps.post({
292
+ kind: 'rendered',
293
+ id: job.id,
294
+ bytes: buffer,
295
+ width: raster.width,
296
+ height: raster.height,
297
+ rowBytes: raster.rowBytes,
298
+ format: raster.format,
299
+ alpha: raster.alpha,
300
+ }, [buffer]);
301
+ }
302
+ catch (error) {
303
+ postError(job.id, error);
304
+ }
305
+ }
306
+ finally {
307
+ inFlight = null;
308
+ }
309
+ // THE CANCEL WINDOW. See the header.
310
+ //
311
+ // UNCONDITIONAL, AND `if (queue.length > 0)` IS NOT AN OPTIMISATION HERE
312
+ // — it is a correctness bug. AN EMPTY QUEUE DOES NOT MEAN NOTHING IS
313
+ // COMING: messages arrive one per event-loop turn, so the queue is fed
314
+ // SERIALLY and `drain` sees a queue of exactly ONE almost every time it
315
+ // looks. Measured in a real Chrome `{type:'module'}` worker
316
+ // (`/c/tmp/mc/web`): three renders posted back-to-back from the main
317
+ // thread were RECEIVED at t=0, t=60 and t=120 ms with a 60 ms render
318
+ // between each — one delivery per turn, never a batch, and identically
319
+ // so under all three candidate yields.
320
+ //
321
+ // Skip the yield when that queue is empty and `drain` returns after
322
+ // every single tile, `draining` goes false, and the next render is then
323
+ // rendered synchronously inside its own message handler — with the
324
+ // `cancel` still sitting untouched behind it. This trailing yield is
325
+ // what holds `draining` true for one macrotask, so everything that
326
+ // arrives in that turn is batched and re-checked against `cancelled`
327
+ // before any of it is rendered.
328
+ //
329
+ // MEASURED, not argued: adding that guard turns 'renders only the tile
330
+ // that was not aborted' red (3 tiles rendered instead of 1) and leaves
331
+ // the other 348 tests green — the same signature as deleting this line
332
+ // outright. The turn it "wastes" after the genuinely last tile costs
333
+ // 0.008 ms now that the yield is not a clamped timer, which is the whole
334
+ // of what audit item A9 was worth. 'yields after the last job too, so a
335
+ // serially fed queue can still be cancelled' pins this.
336
+ await yieldToEventLoop();
337
+ }
338
+ }
339
+ finally {
340
+ draining = false;
341
+ }
342
+ }
343
+ function dispose() {
344
+ if (disposed)
345
+ return;
346
+ disposed = true;
347
+ queue.length = 0;
348
+ cancelled.clear();
349
+ document?.close();
350
+ document = null;
351
+ // An open still in flight closes itself when it lands — see the `disposed`
352
+ // check in the resolve handler. Swallow its rejection so a discarded worker
353
+ // never produces an unhandled rejection.
354
+ opening?.catch(() => { });
355
+ }
356
+ return {
357
+ handle(request) {
358
+ if (disposed)
359
+ return;
360
+ switch (request.kind) {
361
+ case 'open': {
362
+ if (opening !== null) {
363
+ postError(request.id, new PdfError('backend-failure', 'This worker already has a document open. A worker owns ' +
364
+ 'exactly one document, for the whole of its life.'));
365
+ return;
366
+ }
367
+ opening = open(request);
368
+ opening.then(opened => {
369
+ document = opened;
370
+ if (disposed) {
371
+ // Lost the race with a close. Nothing has been posted, so the
372
+ // main thread never learns about a document it already
373
+ // abandoned — but the heap allocation is real and must go.
374
+ opened.close();
375
+ return;
376
+ }
377
+ deps.post({
378
+ kind: 'opened',
379
+ id: request.id,
380
+ pageCount: opened.pageCount,
381
+ // A copy, because the array crosses a structured-clone boundary
382
+ // and the engine's own is readonly on purpose.
383
+ pages: opened.pages.map(page => ({ ...page })),
384
+ });
385
+ }, error => {
386
+ postError(request.id, error);
387
+ });
388
+ return;
389
+ }
390
+ case 'render': {
391
+ queue.push(request);
392
+ void drain();
393
+ return;
394
+ }
395
+ case 'cancel': {
396
+ // Only if something can still consume it. A cancel that arrives after
397
+ // its render was answered has nothing to match, and recording it
398
+ // anyway is what made this set grow without bound.
399
+ if (inFlight === request.id ||
400
+ queue.some(job => job.id === request.id)) {
401
+ cancelled.add(request.id);
402
+ }
403
+ return;
404
+ }
405
+ case 'close': {
406
+ dispose();
407
+ return;
408
+ }
409
+ }
410
+ },
411
+ dispose,
412
+ };
413
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `@reekon-tools/react-native-pdf-canvas/worker` — the module worker entry.
3
+ *
4
+ * THE ONLY MODULE IN THIS PACKAGE THAT NAMES `@embedpdf/pdfium`. It is an
5
+ * OPTIONAL peer dependency: a consumer who ships native only never resolves this
6
+ * file, and nothing else in the package imports it — not even for a type (see
7
+ * `PdfiumWasmModule` in `./pdfium.ts`).
8
+ *
9
+ * It is also the file that PROVES the hand-written binding matches the real
10
+ * module. `bindPdfium` takes a structural type, which is what keeps `./engine.ts`
11
+ * pure and testable; the risk of a structural type is silent drift, and the
12
+ * answer to that is right below — this file passes the genuine
13
+ * `WrappedPdfiumModule` to it and `tsc --noEmit` checks every name, arity and
14
+ * return type. A renamed export or a changed signature in a `@embedpdf/pdfium`
15
+ * bump fails the BUILD, not the browser.
16
+ *
17
+ * ------------------------------------------------------------------------
18
+ * HOW A HOST REACHES IT
19
+ * ------------------------------------------------------------------------
20
+ *
21
+ * import PdfCanvasWorker from
22
+ * '@reekon-tools/react-native-pdf-canvas/worker?worker'; // Vite
23
+ *
24
+ * configureWebPdfium({
25
+ * createWorker: () => new PdfCanvasWorker(),
26
+ * wasm: {url: pdfiumWasmUrl},
27
+ * });
28
+ *
29
+ * Any other way of producing a MODULE worker is equally fine — the package takes
30
+ * a factory and never builds a URL of its own. See `./client.ts`.
31
+ *
32
+ * ------------------------------------------------------------------------
33
+ * WHY THERE IS ALMOST NOTHING HERE
34
+ * ------------------------------------------------------------------------
35
+ *
36
+ * Because a worker entry is the one part of this backend that cannot run under
37
+ * vitest in node — there is no DOM `Worker`, and `node:worker_threads` speaks a
38
+ * different API. So everything that could hold a bug lives in `./session.ts` and
39
+ * `./engine.ts`, which take their dependencies as arguments and are driven in
40
+ * node against the REAL PDFium module by an in-process worker stub. What is left
41
+ * unexercised is exactly the glue below: two `self` bindings and the wasm
42
+ * options.
43
+ */
44
+ export {};
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `@reekon-tools/react-native-pdf-canvas/worker` — the module worker entry.
3
+ *
4
+ * THE ONLY MODULE IN THIS PACKAGE THAT NAMES `@embedpdf/pdfium`. It is an
5
+ * OPTIONAL peer dependency: a consumer who ships native only never resolves this
6
+ * file, and nothing else in the package imports it — not even for a type (see
7
+ * `PdfiumWasmModule` in `./pdfium.ts`).
8
+ *
9
+ * It is also the file that PROVES the hand-written binding matches the real
10
+ * module. `bindPdfium` takes a structural type, which is what keeps `./engine.ts`
11
+ * pure and testable; the risk of a structural type is silent drift, and the
12
+ * answer to that is right below — this file passes the genuine
13
+ * `WrappedPdfiumModule` to it and `tsc --noEmit` checks every name, arity and
14
+ * return type. A renamed export or a changed signature in a `@embedpdf/pdfium`
15
+ * bump fails the BUILD, not the browser.
16
+ *
17
+ * ------------------------------------------------------------------------
18
+ * HOW A HOST REACHES IT
19
+ * ------------------------------------------------------------------------
20
+ *
21
+ * import PdfCanvasWorker from
22
+ * '@reekon-tools/react-native-pdf-canvas/worker?worker'; // Vite
23
+ *
24
+ * configureWebPdfium({
25
+ * createWorker: () => new PdfCanvasWorker(),
26
+ * wasm: {url: pdfiumWasmUrl},
27
+ * });
28
+ *
29
+ * Any other way of producing a MODULE worker is equally fine — the package takes
30
+ * a factory and never builds a URL of its own. See `./client.ts`.
31
+ *
32
+ * ------------------------------------------------------------------------
33
+ * WHY THERE IS ALMOST NOTHING HERE
34
+ * ------------------------------------------------------------------------
35
+ *
36
+ * Because a worker entry is the one part of this backend that cannot run under
37
+ * vitest in node — there is no DOM `Worker`, and `node:worker_threads` speaks a
38
+ * different API. So everything that could hold a bug lives in `./session.ts` and
39
+ * `./engine.ts`, which take their dependencies as arguments and are driven in
40
+ * node against the REAL PDFium module by an in-process worker stub. What is left
41
+ * unexercised is exactly the glue below: two `self` bindings and the wasm
42
+ * options.
43
+ */
44
+ import { init } from '@embedpdf/pdfium';
45
+ import { bindPdfium } from './pdfium.js';
46
+ import { createWorkerSession } from './session.js';
47
+ /**
48
+ * Compile the wasm and initialise the library.
49
+ *
50
+ * `{url}` goes through Emscripten's `locateFile` so it can stream-compile;
51
+ * `{binary}` skips the fetch entirely and is what the node tests use. Neither
52
+ * form is guessed at — `DEFAULT_PDFIUM_WASM_URL` in `@embedpdf/pdfium` points at
53
+ * a jsdelivr copy pinned to ITS version, and silently fetching a different build
54
+ * than the JS bindings expect is precisely the failure the host-supplied locator
55
+ * exists to prevent.
56
+ *
57
+ * `PDFiumExt_Init()` is `@embedpdf/pdfium`'s initialiser and stands in for
58
+ * `FPDF_InitLibrary`. Once per module instance, before any other call.
59
+ */
60
+ async function loadPdfium(wasm) {
61
+ const module = await init('binary' in wasm ? { wasmBinary: wasm.binary } : { locateFile: () => wasm.url });
62
+ module.PDFiumExt_Init();
63
+ return bindPdfium(module);
64
+ }
65
+ const session = createWorkerSession({
66
+ post: (response, transfer) => {
67
+ self.postMessage(response, transfer);
68
+ },
69
+ loadPdfium,
70
+ });
71
+ self.addEventListener('message', event => {
72
+ const data = event?.data;
73
+ if (data === undefined || data === null)
74
+ return;
75
+ session.handle(data);
76
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The ONE component.
3
+ *
4
+ * It draws whatever `content` says, at the doc-space rects `content` carries, and
5
+ * it does nothing else. No gestures, no viewport, no shared values, no `<Canvas>`.
6
+ * It is meant to be mounted inside SOMEBODY ELSE'S `<Group transform={...}>`:
7
+ *
8
+ * <Canvas>
9
+ * <Group transform={worldTransform}>
10
+ * <PdfContentView content={content} />
11
+ * ...the host's own annotation layers...
12
+ * </Group>
13
+ * </Canvas>
14
+ *
15
+ * THE ONE RULE: the transform decides WHEN to rasterize, never WHAT to draw. Every
16
+ * number this component hands to Skia is a plain JS number read off a frozen
17
+ * `PdfContent`, so a pan or a zoom re-records the host's scene without this
18
+ * subtree changing at all.
19
+ */
20
+ import type { ReactNode } from 'react';
21
+ import type { GroupProps } from '@shopify/react-native-skia';
22
+ import type { PdfContent, Sampling } from '../types.js';
23
+ /**
24
+ * The blend mode the Skia DOM actually accepts — the uncapitalised string union
25
+ * (`'srcOver'`, `'multiply'`, …), not the numeric `BlendMode` enum. Derived from
26
+ * `GroupProps` rather than restated so it cannot drift from the renderer.
27
+ */
28
+ export type PdfBlendMode = NonNullable<GroupProps['blendMode']>;
29
+ export interface PdfContentViewProps {
30
+ /**
31
+ * The frozen snapshot to draw. A new object identity per epoch is the swap
32
+ * signal; see the `PdfContent` doc in types.ts for why a mutated handle cannot
33
+ * work.
34
+ */
35
+ content: PdfContent;
36
+ /** Forwarded to the wrapping `<Group>`. */
37
+ zIndex?: number;
38
+ /**
39
+ * How the rasters are resampled when the host transform does not land them at
40
+ * exactly 1:1.
41
+ *
42
+ * MIPMAPS DO NOT WORK HERE AND MUST NOT BE ATTEMPTED. RN Skia's `ImageCmd::draw`
43
+ * hardcodes `kStrict_SrcRectConstraint`, and Skia's own `SkCanvas.h` says of
44
+ * that constraint that it "disables mipmaps and anisotropic filtering" — the
45
+ * sampler is forbidden from reading outside the src rect, which is exactly what
46
+ * a mip level requires. Passing `MipmapMode.Linear` here does not error; it is
47
+ * silently ignored, and you get the same aliasing plus the memory of the mip
48
+ * chain.
49
+ *
50
+ * The consequence is structural: MINIFICATION CANNOT BE FIXED BY SAMPLING. It is
51
+ * fixed by never rastering a layer that will be drawn minified — which is why
52
+ * the base layer is sized from the fit display scale times `baseQuality` rather
53
+ * than from a fixed pixel cap. Minification is a scheduling problem here, not a
54
+ * filtering one.
55
+ *
56
+ * @default 'linear'
57
+ */
58
+ sampling?: Sampling;
59
+ /**
60
+ * Colour of the sheet drawn under each page.
61
+ *
62
+ * Defaults to `null`, meaning NOTHING is drawn under an unrastered page. That
63
+ * default is deliberate: painting opaque white under a PDF with a transparent
64
+ * background is a real bug, and the consumer has very often already painted a
65
+ * surface (a `<Fill>`, a page-shadow layer, a themed canvas) that this rect
66
+ * would cover.
67
+ *
68
+ * @default null
69
+ */
70
+ paperColor?: string | null;
71
+ /**
72
+ * Forwarded to the wrapping `<Group>`.
73
+ *
74
+ * CAVEAT: Skia group opacity is folded into each child's paint, not composited
75
+ * once. Because the base and detail layers deliberately OVERLAP, a value below 1
76
+ * lets the base show through the detail and the seams become visible. For a
77
+ * uniform fade, wrap this component in your own `<Group layer={paint}>` so the
78
+ * whole stack composites once.
79
+ */
80
+ opacity?: number;
81
+ /** Forwarded to the wrapping `<Group>`. */
82
+ blendMode?: PdfBlendMode;
83
+ /**
84
+ * Paint MODIFIER declarations only — `<ColorFilter>` / `<ImageFilter>` /
85
+ * `<Shader>` / `<PathEffect>` nodes (e.g. an invert filter for a dark-mode PDF).
86
+ *
87
+ * They are emitted FIRST, before any drawing, because RN Skia collects
88
+ * declarations as they are visited and the first drawing consumes them.
89
+ *
90
+ * Do NOT pass a `<Paint>`. A Paint child is a *second* paint, so the node is
91
+ * drawn TWICE — once with the declared paint and once with the inherited one —
92
+ * and `paintDeclarations.clear()` runs on that first drawing, so the extra paint
93
+ * would apply to the base raster and to none of the detail rasters. In `__DEV__`
94
+ * this is warned about.
95
+ */
96
+ children?: ReactNode;
97
+ }
98
+ export declare function PdfContentView({ content, zIndex, sampling, paperColor, opacity, blendMode, children, }: PdfContentViewProps): import("react").JSX.Element;