@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
package/dist/cache.js ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Refcounted, byte-budgeted raster cache.
3
+ *
4
+ * Two things make this different from a plain LRU:
5
+ *
6
+ * 1. REFCOUNTS, NOT LIVENESS GUESSES. A raster is drawable exactly while some
7
+ * PdfContent slot references it. `acquire`/`release` count those slots, so a
8
+ * raster held by both the live detail set and the retiring set has refs 2 and
9
+ * survives until both are gone. Nothing referenced is ever evicted.
10
+ *
11
+ * 2. THE RETIRE QUEUE. A refcount reaching zero does NOT dispose. The entry goes
12
+ * to a queue that the controller flushes at a point that already causes a
13
+ * React commit, because freeing an SkImage that is still in a mounted Skia
14
+ * node draws a corpse. Retirement must never own a timer or a commit of its
15
+ * own — see `flushRetired`.
16
+ *
17
+ * 3. THE DISPOSAL FENCE, which is what makes point 2 actually true.
18
+ *
19
+ * MEASURED: releasing and flushing in the SAME turn — which is what the
20
+ * controller's commit did — is a delay of zero. The entry left `content` and
21
+ * was freed before React had even been notified, let alone re-rendered, so
22
+ * every Skia node still holding it was holding a deleted object. In a browser
23
+ * that is `BindingError: Cannot pass deleted object as a pointer of type
24
+ * Image const*`, thrown out of RN Skia's own `drawImage -> Image.width` while
25
+ * it RE-RECORDS the picture from the mounted tree: 16 occurrences over six
26
+ * high-zoom pans, 3-4 ms after an epoch commit, every time.
27
+ *
28
+ * So the queue is fenced, and the fence is a CLOCK the cache is told about
29
+ * rather than one it assumes:
30
+ *
31
+ * release(key, contentEpoch) tags the entry with the epoch of the content
32
+ * object that stopped referencing it.
33
+ * flushRetired(contentEpoch) frees only entries tagged STRICTLY OLDER than
34
+ * `contentEpoch` — i.e. one whole epoch of
35
+ * grace, which is the commit clock and is a
36
+ * heuristic: it assumes the renderer mounted
37
+ * epoch N before the controller published N+1.
38
+ * notePresented(epoch) the SOUND clock, IF a caller can honestly
39
+ * supply one: everything tagged `epoch` or older
40
+ * is treated as provably unreferenced and freed
41
+ * at once — sooner than the commit clock would.
42
+ *
43
+ * "PRESENTED" MEANS DRAWN, NOT MOUNTED, AND THE DIFFERENCE IS MEASURED. Wiring
44
+ * this to React — a `PdfContentView` prop reporting `content.epoch` from a
45
+ * PASSIVE effect, which is the latest signal a component can produce —
46
+ * REINTRODUCES the corpse it exists to prevent. Controlled A/B in rock-desktop's
47
+ * probe, 14 high-zoom pans per arm, twice each:
48
+ *
49
+ * onPresented wired 2, 2 `BindingError: ... Image const*`
50
+ * commit clock only 0, 0
51
+ *
52
+ * The reason is that the images are held by the RECORDED PICTURE, not by the
53
+ * React tree. RN Skia re-records on its own rAF, so after React has committed
54
+ * epoch N there is still a picture in hand that references epoch N-1's images,
55
+ * and `play()` -> `drawImage` -> `Image.width` reads one that has just been
56
+ * freed. Every stack in that A/B was exactly that. A commit is therefore NOT a
57
+ * presentation, and no React-reachable signal on web is late enough to be one.
58
+ *
59
+ * So this is NOT wired, deliberately, and must not be wired from an effect. It
60
+ * stays for a caller driving `createPdfController` against a renderer that has
61
+ * a genuine post-paint callback. Absent that, the commit clock is the correct
62
+ * answer and its only cost is holding one epoch's dropped rasters while the
63
+ * viewport is at rest.
64
+ *
65
+ * An UNTAGGED release (no epoch) means "nothing of mine is mounted any more",
66
+ * and so does a `flushRetired()` with no epoch: both are the teardown path,
67
+ * where immediate disposal is the correct answer and the original behaviour.
68
+ *
69
+ * The budget is a SOFT cap. Running out never throws and never blanks a page:
70
+ * the last resort is to refuse the newcomer and keep drawing the base we already
71
+ * have. Degrade, never crash.
72
+ */
73
+ const DEFAULT_BASE_RETENTION_RADIUS = 2;
74
+ export function createRasterCache(options) {
75
+ const { budgetBytes, dispose } = options;
76
+ const baseRetentionRadius = options.baseRetentionRadius ?? DEFAULT_BASE_RETENTION_RADIUS;
77
+ const entries = new Map();
78
+ const retireQueue = new Set();
79
+ const evictionListeners = new Set();
80
+ /**
81
+ * Disposal guard. Keyed on the raster object rather than on an entry flag so a
82
+ * raster refused by `put` — which never becomes an entry — is covered too.
83
+ */
84
+ const alreadyDisposed = new WeakSet();
85
+ let liveWindow = new Set();
86
+ let totalBytes = 0;
87
+ let tick = 0;
88
+ /**
89
+ * The two clocks the fence can run on. `presentedEpoch` is null until a
90
+ * renderer reports one, and from then on it is the ONLY clock — the commit
91
+ * clock below is a guess about the renderer, and a guess must not be allowed to
92
+ * overrule the renderer itself when it says it is behind.
93
+ */
94
+ let presentedEpoch = null;
95
+ /** Highest content epoch any caller has named, on a release or a flush. */
96
+ let latestEpoch = Number.NEGATIVE_INFINITY;
97
+ function noteEpoch(epoch) {
98
+ if (Number.isFinite(epoch) && epoch > latestEpoch)
99
+ latestEpoch = epoch;
100
+ }
101
+ /**
102
+ * The newest epoch tag that may be freed. An entry tagged ABOVE this may still
103
+ * be referenced by a mounted Skia node, so it is untouchable — by the flush and
104
+ * by eviction alike, since a corpse does not care which door freed it.
105
+ */
106
+ function disposalFence() {
107
+ if (presentedEpoch !== null)
108
+ return presentedEpoch;
109
+ // The commit clock: one whole epoch of grace. -Infinity - 1 is -Infinity, so
110
+ // before any epoch is named this frees untagged entries and nothing else.
111
+ return latestEpoch - 1;
112
+ }
113
+ function fenced(entry, fence) {
114
+ return entry.retired && entry.retiredAt > fence;
115
+ }
116
+ function disposeOnce(raster) {
117
+ if (alreadyDisposed.has(raster))
118
+ return;
119
+ alreadyDisposed.add(raster);
120
+ try {
121
+ dispose(raster);
122
+ }
123
+ catch {
124
+ // Disposal is best-effort teardown. A backend that throws here must not
125
+ // abort an eviction loop half-way and leave the byte count lying.
126
+ }
127
+ }
128
+ function notifyEviction(raster, reason) {
129
+ for (const listener of evictionListeners)
130
+ listener(raster, reason);
131
+ }
132
+ /**
133
+ * A live page's base is the anti-blank mechanism: it is the only thing
134
+ * guaranteed to be drawable while a detail raster is in flight. It is never
135
+ * evicted and never flushed, whatever the budget says.
136
+ */
137
+ function isProtected(entry) {
138
+ return entry.raster.role === 'base' && liveWindow.has(entry.raster.page);
139
+ }
140
+ function withinBaseRetention(page) {
141
+ for (const live of liveWindow) {
142
+ if (Math.abs(live - page) <= baseRetentionRadius)
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ function removeEntry(key, entry) {
148
+ entries.delete(key);
149
+ retireQueue.delete(key);
150
+ totalBytes -= entry.raster.bytes;
151
+ if (totalBytes < 0)
152
+ totalBytes = 0;
153
+ }
154
+ /**
155
+ * The documented eviction order. Each category considers only unreferenced,
156
+ * unprotected entries, and picks the least-recently-used within the category
157
+ * before moving on:
158
+ *
159
+ * 1. detail for pages outside the live window — off screen, worthless
160
+ * 2. retired entries the fence has cleared — nothing can be drawing them
161
+ * 3. base for pages outside the retention radius
162
+ * 4. retired entries the fence has NOT cleared — LAST RESORT, see below
163
+ * 5. (caller's job) refuse the newcomer
164
+ *
165
+ * CATEGORY 4 IS WHY THIS CHANGE IS AN ORDERING CHANGE AND NOT A RETENTION ONE.
166
+ * A fenced entry may still sit in a mounted Skia node, so freeing it is the
167
+ * corpse this cache exists to avoid — but making it strictly unevictable would
168
+ * shrink the evictable set, and on a phone at the default 96 MiB budget that
169
+ * turns into refused detail pieces on a path proven by 57 instrumented tests
170
+ * and no device to re-run them on. So the set of entries eviction may take is
171
+ * EXACTLY what it always was, and all that changes is that a possibly-mounted
172
+ * raster is now the last thing taken rather than the second. It is reached only
173
+ * when the budget cannot otherwise be met at all — `evict` is 0 across every
174
+ * browser run of the harness, at dpr 1 and dpr 2.
175
+ *
176
+ * O(n) per eviction over an n in the low tens; a heap here would be more code
177
+ * than it saves.
178
+ */
179
+ function pickVictim() {
180
+ const fence = disposalFence();
181
+ const categories = [
182
+ {
183
+ reason: 'page-window',
184
+ match: e => !fenced(e, fence) &&
185
+ e.raster.role === 'detail' &&
186
+ !liveWindow.has(e.raster.page),
187
+ },
188
+ { reason: 'epoch', match: e => e.retired && !fenced(e, fence) },
189
+ {
190
+ reason: 'page-window',
191
+ match: e => !fenced(e, fence) &&
192
+ e.raster.role === 'base' &&
193
+ !withinBaseRetention(e.raster.page),
194
+ },
195
+ { reason: 'epoch', match: e => fenced(e, fence) },
196
+ ];
197
+ for (const category of categories) {
198
+ let bestKey = null;
199
+ let best = null;
200
+ for (const [key, entry] of entries) {
201
+ if (entry.refs > 0 || isProtected(entry))
202
+ continue;
203
+ if (!category.match(entry))
204
+ continue;
205
+ if (best === null || entry.usedAt < best.usedAt) {
206
+ best = entry;
207
+ bestKey = key;
208
+ }
209
+ }
210
+ if (best !== null && bestKey !== null)
211
+ return { key: bestKey, entry: best, reason: category.reason };
212
+ }
213
+ return null;
214
+ }
215
+ function evictUntil(headroom) {
216
+ while (totalBytes + headroom > budgetBytes) {
217
+ const victim = pickVictim();
218
+ if (victim === null)
219
+ return;
220
+ removeEntry(victim.key, victim.entry);
221
+ notifyEviction(victim.entry.raster, victim.reason);
222
+ disposeOnce(victim.entry.raster);
223
+ }
224
+ }
225
+ function touch(entry) {
226
+ entry.usedAt = ++tick;
227
+ }
228
+ /**
229
+ * The one place a retired entry is freed. Everything tagged at or below `fence`
230
+ * goes; everything above it STAYS IN THE QUEUE, which is the whole difference
231
+ * between a delay of one epoch and a delay of nothing.
232
+ */
233
+ function disposeEligible(fence) {
234
+ // Snapshot: disposal mutates the queue.
235
+ for (const key of Array.from(retireQueue)) {
236
+ const entry = entries.get(key);
237
+ if (entry === undefined) {
238
+ retireQueue.delete(key);
239
+ continue;
240
+ }
241
+ if (entry.refs > 0) {
242
+ // Re-acquired between release and flush — a cache hit on the next
243
+ // epoch's plan. Nothing to do.
244
+ entry.retired = false;
245
+ retireQueue.delete(key);
246
+ continue;
247
+ }
248
+ if (isProtected(entry)) {
249
+ // A live page's base outlives the content object that referenced it.
250
+ // Keep it cached and unreferenced rather than disposing: re-rastering
251
+ // it is exactly the blank frame this whole design exists to avoid.
252
+ entry.retired = false;
253
+ retireQueue.delete(key);
254
+ continue;
255
+ }
256
+ if (entry.retiredAt > fence)
257
+ continue; // a renderer may still hold it
258
+ removeEntry(key, entry); // also drops it from the queue
259
+ disposeOnce(entry.raster);
260
+ }
261
+ }
262
+ return {
263
+ get(key) {
264
+ const entry = entries.get(key);
265
+ if (entry === undefined)
266
+ return undefined;
267
+ touch(entry);
268
+ return entry.raster;
269
+ },
270
+ has(key) {
271
+ return entries.has(key);
272
+ },
273
+ put(raster) {
274
+ const existing = entries.get(raster.key);
275
+ if (existing !== undefined) {
276
+ touch(existing);
277
+ // Same key, different bytes: the incumbent is already referenced by
278
+ // mounted content, so it wins and the newcomer is freed. The caller is
279
+ // documented to read its reference back out of the cache.
280
+ if (existing.raster !== raster)
281
+ disposeOnce(raster);
282
+ return true;
283
+ }
284
+ // Nothing that can never fit is worth emptying the cache for.
285
+ if (raster.bytes > budgetBytes) {
286
+ notifyEviction(raster, 'budget');
287
+ disposeOnce(raster);
288
+ return false;
289
+ }
290
+ evictUntil(raster.bytes);
291
+ if (totalBytes + raster.bytes > budgetBytes) {
292
+ // Step 4: refuse. Everything still cached is either referenced or a live
293
+ // base, and both are worth more than this newcomer.
294
+ notifyEviction(raster, 'budget');
295
+ disposeOnce(raster);
296
+ return false;
297
+ }
298
+ entries.set(raster.key, {
299
+ raster,
300
+ refs: 0,
301
+ usedAt: ++tick,
302
+ retired: false,
303
+ retiredAt: Number.NEGATIVE_INFINITY,
304
+ });
305
+ totalBytes += raster.bytes;
306
+ return true;
307
+ },
308
+ acquire(key) {
309
+ const entry = entries.get(key);
310
+ if (entry === undefined)
311
+ return undefined;
312
+ entry.refs += 1;
313
+ entry.retired = false;
314
+ retireQueue.delete(key);
315
+ touch(entry);
316
+ return entry.raster;
317
+ },
318
+ release(key, contentEpoch) {
319
+ const entry = entries.get(key);
320
+ if (entry === undefined)
321
+ return;
322
+ if (entry.refs > 0)
323
+ entry.refs -= 1;
324
+ if (entry.refs === 0) {
325
+ entry.retired = true;
326
+ // A second release of an already-retired entry re-stamps it with the
327
+ // newer epoch, which holds it longer. Late is safe; early is the corpse.
328
+ entry.retiredAt = contentEpoch ?? Number.NEGATIVE_INFINITY;
329
+ if (contentEpoch !== undefined)
330
+ noteEpoch(contentEpoch);
331
+ retireQueue.add(key);
332
+ }
333
+ },
334
+ refCount(key) {
335
+ return entries.get(key)?.refs ?? 0;
336
+ },
337
+ flushRetired(contentEpoch) {
338
+ if (contentEpoch !== undefined)
339
+ noteEpoch(contentEpoch);
340
+ // No epoch is the teardown call: nothing is mounted, so nothing is fenced.
341
+ disposeEligible(contentEpoch === undefined ? Number.POSITIVE_INFINITY : disposalFence());
342
+ },
343
+ notePresented(epoch) {
344
+ if (!Number.isFinite(epoch))
345
+ return;
346
+ presentedEpoch =
347
+ presentedEpoch === null ? epoch : Math.max(presentedEpoch, epoch);
348
+ noteEpoch(epoch);
349
+ // Freed HERE rather than at the next flush, and that is the point of the
350
+ // sound clock: at rest there is no next commit to piggyback on, so the
351
+ // commit clock would hold one epoch's dropped rasters for as long as the
352
+ // user sits still. This is a call the host makes from a commit it already
353
+ // has, so retirement still owns no timer and no commit of its own.
354
+ disposeEligible(disposalFence());
355
+ },
356
+ setLiveWindow(pages) {
357
+ liveWindow = new Set(pages);
358
+ },
359
+ clear() {
360
+ for (const entry of entries.values())
361
+ disposeOnce(entry.raster);
362
+ entries.clear();
363
+ retireQueue.clear();
364
+ totalBytes = 0;
365
+ // Back to un-armed: a cleared cache holds no tags, so a stale fence could
366
+ // only mis-judge whatever is put into it next.
367
+ presentedEpoch = null;
368
+ latestEpoch = Number.NEGATIVE_INFINITY;
369
+ },
370
+ stats() {
371
+ let referenced = 0;
372
+ for (const entry of entries.values())
373
+ if (entry.refs > 0)
374
+ referenced += 1;
375
+ return {
376
+ bytes: totalBytes,
377
+ entries: entries.size,
378
+ retired: retireQueue.size,
379
+ referenced,
380
+ };
381
+ },
382
+ subscribeEvictions(listener) {
383
+ evictionListeners.add(listener);
384
+ return () => {
385
+ evictionListeners.delete(listener);
386
+ };
387
+ },
388
+ };
389
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A monotonic, sub-millisecond clock — for MEASURING durations only.
3
+ *
4
+ * Deliberately NOT the controller's `now`. That one is injected so a test can
5
+ * drive the epoch state machine on a fake clock, and its resolution (`Date.now`,
6
+ * 1 ms) is fine for cadence decisions measured in tens of milliseconds. It is
7
+ * useless for the phase split this module exists to serve: a `decodeBase64` of a
8
+ * 5.6 M-char string and a `Skia.Data.fromBytes` of 4 MiB are the two terms being
9
+ * told apart, and quantising both to 1 ms is how you end up "proving" a number
10
+ * you had already assumed.
11
+ *
12
+ * PURE. `performance` is a global in Hermes, in node and in every browser, but
13
+ * it is reached through a guard rather than assumed, because the whole point of
14
+ * this package's core is that it loads with neither `react-native` nor a DOM
15
+ * present. `Date.now` is the fallback and it is honest — a phase that only ever
16
+ * reports whole milliseconds is a phase measured on the fallback.
17
+ */
18
+ type Clock = () => number;
19
+ /** Milliseconds since an arbitrary origin. Differences are the only meaning. */
20
+ export declare const monotonicNow: Clock;
21
+ export {};
package/dist/clock.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A monotonic, sub-millisecond clock — for MEASURING durations only.
3
+ *
4
+ * Deliberately NOT the controller's `now`. That one is injected so a test can
5
+ * drive the epoch state machine on a fake clock, and its resolution (`Date.now`,
6
+ * 1 ms) is fine for cadence decisions measured in tens of milliseconds. It is
7
+ * useless for the phase split this module exists to serve: a `decodeBase64` of a
8
+ * 5.6 M-char string and a `Skia.Data.fromBytes` of 4 MiB are the two terms being
9
+ * told apart, and quantising both to 1 ms is how you end up "proving" a number
10
+ * you had already assumed.
11
+ *
12
+ * PURE. `performance` is a global in Hermes, in node and in every browser, but
13
+ * it is reached through a guard rather than assumed, because the whole point of
14
+ * this package's core is that it loads with neither `react-native` nor a DOM
15
+ * present. `Date.now` is the fallback and it is honest — a phase that only ever
16
+ * reports whole milliseconds is a phase measured on the fallback.
17
+ */
18
+ const resolve = () => {
19
+ const perf = globalThis
20
+ ?.performance;
21
+ if (perf !== undefined && typeof perf.now === 'function') {
22
+ const now = perf.now.bind(perf);
23
+ // Trust it only if it actually returns a number. A shimmed `performance`
24
+ // that returns undefined would silently turn every duration into NaN.
25
+ if (typeof now() === 'number')
26
+ return now;
27
+ }
28
+ return Date.now;
29
+ };
30
+ /** Milliseconds since an arbitrary origin. Differences are the only meaning. */
31
+ export const monotonicNow = /*#__PURE__*/ resolve();
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The cadence engine. Framework-free: no React, no reanimated, no Skia calls.
3
+ *
4
+ * THE ONE RULE: the transform decides WHEN to rasterize, never WHAT to draw.
5
+ * Everything this file emits is a plain-number doc-space rect that is constant
6
+ * for the life of an epoch. The host's <Group transform> does 100% of the pan
7
+ * and zoom; nothing here reads a shared value.
8
+ *
9
+ * Shape of the machine:
10
+ *
11
+ * hint() continuous input -> restart a trailing quiet timer (or force an
12
+ * evaluation once maxStaleMs has elapsed since the last commit)
13
+ * settle() discrete end -> evaluate now, subject to minEpochIntervalMs
14
+ *
15
+ * An evaluation asks `planRasters` WHAT should exist and `shouldReraster`
16
+ * WHETHER it is worth producing, and either does nothing or dispatches one
17
+ * raster run. A run renders its items through `maxConcurrentRenders` lanes —
18
+ * one at a time unless the backend says it can do better — and its result is
19
+ * committed as a brand new frozen PdfContent — identity change is the swap
20
+ * signal, because RN Skia's shallowEq is reference equality and a handle
21
+ * mutated in place would never reach the Skia tree.
22
+ *
23
+ * There is one thing the machine does for itself: a run that ends with a piece
24
+ * missing arms a single re-EVALUATION (see `scheduleRepair`), because every
25
+ * other entry point is host-driven and a host at rest produces none.
26
+ *
27
+ * WHAT A COMMIT IS NOT: THE MOMENT THE OLD PIXELS STOP BEING DRAWN. Publishing
28
+ * `content` only changes what the NEXT render will say; the renderer catches up
29
+ * later, and on web it re-records its picture from the currently-mounted tree
30
+ * many times in between. So every release this file makes carries the epoch it
31
+ * happened at (`cache.release(key, epoch)`) and the flush is fenced on that same
32
+ * epoch, and a host that can report presentation (`notePresented`) replaces the
33
+ * guess with the fact. See the fence section of `./cache.js`.
34
+ *
35
+ * THIS FILE OWNS NO GEOMETRY AND NO PLANNING. Every rect operation comes from
36
+ * `./geometry.js` and every decision about what to render, at what scale, under
37
+ * which key comes from `./planner.js`. A private copy of either is how the two
38
+ * silently drift until the tested one is testing nothing that runs.
39
+ */
40
+ import type { DocRect, PageGeometry, PdfContent, PdfController, PdfDiagnostic, PdfRaster, RasterizerHandle, RasterPixels, RasterPolicy, RasterRole } from './types.js';
41
+ import type { RasterCache } from './cache.js';
42
+ export interface ScheduledHandle {
43
+ cancel(): void;
44
+ }
45
+ /**
46
+ * Everything `ingest` needs that is not already in the pixels. It exists because
47
+ * turning RasterPixels into an SkImage is platform code and cannot live in a
48
+ * module that must run under plain Node in vitest.
49
+ */
50
+ export interface RasterIngestMeta {
51
+ key: string;
52
+ page: number;
53
+ role: RasterRole;
54
+ /** Doc space, LAYOUT coordinates — exactly the rect the bytes cover. */
55
+ docRect: DocRect;
56
+ scale: number;
57
+ epoch: number;
58
+ }
59
+ export interface PdfControllerOptions {
60
+ handle: RasterizerHandle;
61
+ /** Where each page sits in doc space. Index in this array IS the page number. */
62
+ pageRects: DocRect[];
63
+ /** Parallel to `pageRects`. Every page, in page order. */
64
+ pages: PageGeometry[];
65
+ policy: RasterPolicy;
66
+ ingest: (pixels: RasterPixels, meta: RasterIngestMeta) => PdfRaster;
67
+ cache: RasterCache;
68
+ /**
69
+ * When present, NO page outside this set is ever planned — neither a base nor
70
+ * a detail piece. This is how the React layer enforces "pages defaults to a
71
+ * window, never all": a 400-page spec book holds ~10 MiB in a single ARCH-D
72
+ * base, so "all" is ~4 GiB of standing allocation before one detail raster
73
+ * exists.
74
+ *
75
+ * It restricts RASTERING only. `pageRects` / `pages` must still describe every
76
+ * page, because `content.pages` is what the host hit-tests and scrolls
77
+ * against, and a page it cannot locate is a page it cannot scroll to.
78
+ */
79
+ allowedPages?: ReadonlySet<number>;
80
+ /**
81
+ * How many pieces of one plan may be rendered at once.
82
+ *
83
+ * COMES FROM THE BACKEND — `PageRasterizer.capabilities.maxConcurrentRenders`,
84
+ * which `usePdfLayer` forwards. Defaulting to 1 here is what keeps a backend
85
+ * that never declared it scheduled exactly as it was: one piece at a time, in
86
+ * plan order, which is the only behaviour every backend has been proven
87
+ * against.
88
+ *
89
+ * The effective bound is `min(this, plan.items.length)`, so a single-piece
90
+ * epoch never spawns a lane it has no work for.
91
+ */
92
+ maxConcurrentRenders?: number;
93
+ /** Injected so tests drive a fake clock. Nothing below calls Date.now. */
94
+ now?: () => number;
95
+ /** Injected so tests drive a fake clock. Nothing below calls setTimeout. */
96
+ schedule?: (fn: () => void, ms: number) => ScheduledHandle;
97
+ onDiagnostic?: (diagnostic: PdfDiagnostic) => void;
98
+ /** Surfaced in the `raster` diagnostic. RasterizerHandle carries no id. */
99
+ backendId?: string;
100
+ }
101
+ export interface PdfControllerInstance extends PdfController {
102
+ getContent(): PdfContent;
103
+ /** useSyncExternalStore-shaped: bare notification, read with getContent(). */
104
+ subscribe(listener: () => void): () => void;
105
+ /**
106
+ * THE RENDERER'S CLOCK, and the only sound one for disposal.
107
+ *
108
+ * Call this once the renderer has MOUNTED content epoch `epoch` — from a layout
109
+ * effect under the component that draws it, not from a timer. Every raster the
110
+ * content objects up to that epoch stopped referencing is then provably
111
+ * unreferenced, and the cache frees it at once.
112
+ *
113
+ * WHY IT EXISTS. `commit` cannot know when the renderer catches up, and on web
114
+ * it is demonstrably not the same instant: RN Skia RE-RECORDS its picture from
115
+ * whatever Skia tree is currently mounted, and a host whose transform lives in
116
+ * React state re-records on every pointermove of a pan. Freeing a raster before
117
+ * React has committed the content that dropped it therefore leaves a deleted
118
+ * SkImage in a tree that is about to be walked — measured as 34 `BindingError:
119
+ * Cannot pass deleted object as a pointer of type Image const*` throws over six
120
+ * high-zoom pans, out of RN Skia's own `drawImage -> Image.width`.
121
+ *
122
+ * OPTIONAL. Without it the cache falls back to the commit clock (one whole
123
+ * epoch of grace, see `RasterCache.flushRetired`), which closes the measured
124
+ * hole but is a heuristic — it assumes the renderer mounts epoch N before the
125
+ * controller publishes N+1 — and it holds one epoch's dropped rasters for as
126
+ * long as the viewport is at rest. Reporting is what makes both exact.
127
+ */
128
+ notePresented(epoch: number): void;
129
+ destroy(): void;
130
+ }
131
+ export declare function createPdfController(options: PdfControllerOptions): PdfControllerInstance;