@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,1002 @@
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 { monotonicNow } from './clock.js';
41
+ import { isEmptyRect, rectIntersects, rectsCover } from './geometry.js';
42
+ import { planRasters, shouldReraster } from './planner.js';
43
+ import { PdfError } from './types.js';
44
+ /* ------------------------------------------------------------------ *
45
+ * Defaults for the injected seams
46
+ *
47
+ * The ONLY place this module touches a real clock. Every other line goes
48
+ * through `now` / `schedule` so a test can run the whole machine in Node.
49
+ * ------------------------------------------------------------------ */
50
+ const DEFAULT_NOW = () => Date.now();
51
+ const DEFAULT_SCHEDULE = (fn, ms) => {
52
+ const id = setTimeout(fn, ms);
53
+ return {
54
+ cancel() {
55
+ clearTimeout(id);
56
+ },
57
+ };
58
+ };
59
+ /* ------------------------------------------------------------------ *
60
+ * Tuning that is genuinely internal (not policy the host should see)
61
+ * ------------------------------------------------------------------ */
62
+ /** Pages this far outside the plan's window lose their base to the cache. */
63
+ const BASE_PAGE_RADIUS = 2;
64
+ /**
65
+ * Containment tolerance in doc points. Both rects are the product of float
66
+ * arithmetic on both sides, and a 1e-9 overhang is not a coverage failure.
67
+ */
68
+ const CONTAINMENT_EPSILON = 1e-6;
69
+ /**
70
+ * First delay before the controller re-evaluates an epoch that committed with a
71
+ * hole in it, and the ceiling the back-off climbs to.
72
+ *
73
+ * WHY A BACK-OFF AND NOT A FIXED RETRY. The repair is a re-EVALUATION, not a
74
+ * retry of a piece: it re-enters `evaluate` against the newest viewport, so every
75
+ * predicate, the min-interval floor and the identical-plan logic still apply, and
76
+ * the plan it produces asks for the same geometry-derived keys — so the pieces
77
+ * that DID land are cache hits and only the hole is re-rendered. But a hole with
78
+ * a permanent cause (a page PDFium refuses, a piece the budget can never fit)
79
+ * would otherwise re-render on a fixed timer for the life of the session.
80
+ * Doubling to a 30s ceiling costs one render a minute in that pathological case
81
+ * and repairs a transient failure in a quarter of a second, which is the case
82
+ * that actually happens.
83
+ *
84
+ * The counter resets whenever the hole CHANGES (a different plan, or a different
85
+ * set of missing keys), because that is evidence the situation is still moving.
86
+ */
87
+ const REPAIR_BASE_MS = 250;
88
+ const REPAIR_MAX_MS = 30000;
89
+ const EMPTY_RASTERS = Object.freeze([]);
90
+ function toPdfError(cause) {
91
+ if (cause instanceof PdfError)
92
+ return cause;
93
+ const message = cause instanceof Error ? cause.message : String(cause);
94
+ return new PdfError('backend-failure', message, { cause });
95
+ }
96
+ export function createPdfController(options) {
97
+ const { handle, policy, ingest, cache, allowedPages } = options;
98
+ const now = options.now ?? DEFAULT_NOW;
99
+ const schedule = options.schedule ?? DEFAULT_SCHEDULE;
100
+ const backendId = options.backendId ?? 'unknown';
101
+ const renderLanes = Math.max(1, Math.floor(options.maxConcurrentRenders ?? 1) || 1);
102
+ /**
103
+ * Diagnostics are OBSERVABILITY, and a host's telemetry callback must never be
104
+ * able to stop the machine. `emit` is called from inside the render lanes, so
105
+ * a throw here would reject a lane — see the note on `lane` for what that
106
+ * costs. Swallowing is the lesser evil, and there is nowhere better to report
107
+ * it than the callback that just failed.
108
+ */
109
+ const emit = (d) => {
110
+ try {
111
+ options.onDiagnostic?.(d);
112
+ }
113
+ catch {
114
+ // Deliberately empty.
115
+ }
116
+ };
117
+ /**
118
+ * `shouldReraster` folds `minEpochIntervalMs` in and reports the reason that
119
+ * WOULD have fired — which cannot be told apart from "nothing fired", since
120
+ * both come back as `{reraster: false, reason: 'stale'}`. The controller has
121
+ * to tell them apart: a blocked predicate re-arms the timer for the remainder
122
+ * of the floor, whereas nothing-fired stops. So the predicates are asked with
123
+ * the floor removed and the floor is applied here, next to the re-arm that
124
+ * implements it.
125
+ */
126
+ const predicatePolicy = { ...policy, minEpochIntervalMs: 0 };
127
+ const geometryByIndex = new Map();
128
+ for (const geometry of options.pages)
129
+ geometryByIndex.set(geometry.index, geometry);
130
+ const pageInfos = options.pageRects.map((pageRect, page) => {
131
+ const geometry = geometryByIndex.get(page);
132
+ const raw = geometry !== undefined && geometry.width > 0
133
+ ? pageRect.width / geometry.width
134
+ : 1;
135
+ return {
136
+ page,
137
+ pageRect,
138
+ layoutScale: Number.isFinite(raw) && raw > 0 ? raw : 1,
139
+ };
140
+ });
141
+ const listeners = new Set();
142
+ const unsubscribeEvictions = cache.subscribeEvictions?.((raster, reason) => {
143
+ emit({ type: 'evict', key: raster.key, bytes: raster.bytes, reason });
144
+ });
145
+ let content = Object.freeze({
146
+ pages: Object.freeze(pageInfos.map(info => Object.freeze({
147
+ page: info.page,
148
+ pageRect: info.pageRect,
149
+ base: EMPTY_RASTERS,
150
+ detail: EMPTY_RASTERS,
151
+ retiring: EMPTY_RASTERS,
152
+ }))),
153
+ epoch: 0,
154
+ settling: false,
155
+ });
156
+ let epoch = 0;
157
+ let epochState = null;
158
+ /** Viewport of the last COMMITTED run. The look-ahead predicate's origin. */
159
+ let rasterViewport = null;
160
+ let committedSignature = null;
161
+ /** Signature of the most recent plan we acted on, for hint() idempotence. */
162
+ let plannedSignature = null;
163
+ let latestViewport = null;
164
+ let lastSeenScale = null;
165
+ let suppressed = false;
166
+ let destroyed = false;
167
+ /**
168
+ * One pending evaluation, whatever armed it (quiet timer or min-interval
169
+ * back-off). Both mean the same thing — "evaluate later against the newest
170
+ * viewport" — so a second one must replace the first, never stack.
171
+ */
172
+ let pending = null;
173
+ let inFlight = null;
174
+ /** Queue depth 1, newest wins. Only the viewport matters; we replan on drain. */
175
+ let queued = null;
176
+ /**
177
+ * The hole the last repair was armed for — plan signature plus the sorted
178
+ * missing keys — and how many times in a row we have seen exactly it. See
179
+ * `REPAIR_BASE_MS`.
180
+ */
181
+ let repairSignature = null;
182
+ let repairAttempts = 0;
183
+ function notify() {
184
+ for (const listener of listeners)
185
+ listener();
186
+ }
187
+ function cancelPending() {
188
+ pending?.cancel();
189
+ pending = null;
190
+ }
191
+ function arm(ms) {
192
+ cancelPending();
193
+ pending = schedule(() => {
194
+ pending = null;
195
+ if (destroyed || latestViewport === null)
196
+ return;
197
+ evaluate(latestViewport, false);
198
+ }, ms);
199
+ }
200
+ /* ---------------------------------------------------------------- *
201
+ * Planning — delegated wholesale to ./planner.js
202
+ * ---------------------------------------------------------------- */
203
+ /**
204
+ * Page INDICES the planner is allowed to consider, which is the intersection
205
+ * of "on screen" and `allowedPages`. Deriving it here rather than in the
206
+ * planner is the split the contract asks for: the planner knows geometry, the
207
+ * controller knows which pages the host is willing to pay for.
208
+ */
209
+ function visiblePagesFor(viewport) {
210
+ const visible = [];
211
+ for (const info of pageInfos) {
212
+ if (allowedPages !== undefined && !allowedPages.has(info.page))
213
+ continue;
214
+ if (isEmptyRect(info.pageRect))
215
+ continue;
216
+ if (!rectIntersects(viewport.visibleDocRect, info.pageRect))
217
+ continue;
218
+ visible.push(info.page);
219
+ }
220
+ return visible;
221
+ }
222
+ function buildPlan(viewport) {
223
+ return planRasters({
224
+ viewport,
225
+ pages: options.pages,
226
+ pageRects: options.pageRects,
227
+ visiblePages: visiblePagesFor(viewport),
228
+ policy,
229
+ });
230
+ }
231
+ const signatureOf = (plan) => plan.items.map(item => item.key).join('|');
232
+ /**
233
+ * Did a predicate fire for `viewport`, judged against an already-rastered
234
+ * state? The floor is deliberately absent — see `predicatePolicy`.
235
+ */
236
+ function firedAgainst(state, stateViewport, viewport) {
237
+ const verdict = shouldReraster({
238
+ next: viewport,
239
+ previous: stateViewport,
240
+ epoch: state,
241
+ policy: predicatePolicy,
242
+ nowMs: now(),
243
+ });
244
+ return verdict.reraster ? verdict.reason : null;
245
+ }
246
+ /**
247
+ * The doc rect of every page a plan planned — the epoch's coverage
248
+ * RESPONSIBILITY (see `EpochState.pageRects`). Read off the plan's own items
249
+ * rather than from the visible set, so a page the plan skipped (outside
250
+ * `allowedPages`, or off screen) is not a region anything is expected to cover.
251
+ */
252
+ function plannedPageRects(plan) {
253
+ const rects = [];
254
+ for (const page of new Set(plan.items.map(item => item.page))) {
255
+ const info = pageInfos[page];
256
+ if (info !== undefined)
257
+ rects.push(info.pageRect);
258
+ }
259
+ return rects;
260
+ }
261
+ /** Does this page have ANYTHING drawable under its detail layer? */
262
+ function hasBase(page) {
263
+ return (content.pages[page]?.base.length ?? 0) > 0;
264
+ }
265
+ /**
266
+ * Is this exact base piece mounted?
267
+ *
268
+ * Per-PIECE, not per-page, and that matters now the base is a grid: a page
269
+ * holding three of its four pieces has a base, but the fourth quarter of it is
270
+ * unpainted. Asking per-page would let that hole sit there until some other
271
+ * predicate happened to fire.
272
+ */
273
+ function hasBasePiece(page, key) {
274
+ return content.pages[page]?.base.some(r => r.key === key) ?? false;
275
+ }
276
+ /* ---------------------------------------------------------------- *
277
+ * Evaluation and dispatch
278
+ * ---------------------------------------------------------------- */
279
+ function evaluate(viewport, forced) {
280
+ if (destroyed)
281
+ return;
282
+ latestViewport = viewport;
283
+ const plan = buildPlan(viewport);
284
+ const signature = signatureOf(plan);
285
+ let reason = firedAgainst(epochState, rasterViewport, viewport);
286
+ if (reason === null) {
287
+ // A page has entered the window without a base of its own. Nothing to
288
+ // compare against, and a page drawn from nothing is the blank frame.
289
+ // `shouldReraster` cannot see this — it is documented never to return
290
+ // 'layout', because a layout change is invisible from a viewport
291
+ // snapshot and its owner has to force the epoch.
292
+ const missingBase = plan.items.some(item => item.role === 'base' && !hasBasePiece(item.page, item.key));
293
+ if (missingBase)
294
+ reason = 'layout';
295
+ }
296
+ // maxStaleMs forces the EVALUATION, not the raster: under continued input
297
+ // the quiet timer never fires, so without this the predicates are never
298
+ // consulted at all. If they still say nothing changed, honour that.
299
+ if (reason === null && forced && signature !== committedSignature)
300
+ reason = 'stale';
301
+ if (reason === null)
302
+ return;
303
+ const elapsed = epochState === null
304
+ ? Number.POSITIVE_INFINITY
305
+ : now() - epochState.committedAtMs;
306
+ if (elapsed < policy.minEpochIntervalMs) {
307
+ // Not a drop: re-arm for the remainder holding the NEWEST viewport, which
308
+ // is what collapses a burst of settles into one raster.
309
+ emit({ type: 'skip', reason: 'min-interval' });
310
+ arm(policy.minEpochIntervalMs - elapsed);
311
+ return;
312
+ }
313
+ cancelPending();
314
+ plannedSignature = signature;
315
+ dispatch(plan, signature, reason, viewport);
316
+ }
317
+ function dispatch(plan, signature, reason, viewport) {
318
+ if (inFlight !== null) {
319
+ // Already producing exactly this. Aborting it to start it again is the
320
+ // pinch-nudge failure in its purest form.
321
+ if (inFlight.signature === signature)
322
+ return;
323
+ queued = viewport;
324
+ // Best effort: PDFKit's draw(with:to:) and Android's PdfRenderer.render
325
+ // are synchronous and uninterruptible once entered, so this often only
326
+ // prevents the NEXT piece from starting.
327
+ inFlight.abort.abort();
328
+ return;
329
+ }
330
+ void run({ plan, signature, viewport, reason, abort: new AbortController() });
331
+ }
332
+ /**
333
+ * Render one plan into rasters, `renderLanes` pieces at a time.
334
+ *
335
+ * WHY LANES AND NOT `Promise.all(items.map(...))`. The bound is the backend's
336
+ * (`RasterizerCapabilities.maxConcurrentRenders`); anything beyond it queues
337
+ * inside the backend, where the controller can no longer see it, cannot stop
338
+ * it, and cannot decide it is no longer worth doing. Lanes keep every decision
339
+ * — abort, supersede, teardown — at the point where the next piece is CLAIMED.
340
+ *
341
+ * THREE THINGS THE PARALLEL FORM HAS TO PRESERVE, all of which a naive
342
+ * `Promise.all` loses:
343
+ *
344
+ * 1. ORDER IS PLAN ORDER, NOT COMPLETION ORDER. Results are written into a
345
+ * SLOT indexed by plan position and compacted at the end. Pieces OVERLAP
346
+ * rather than abut, so the array order is the draw order — and `commit`'s
347
+ * `unchanged` test compares the fresh base against the mounted one element
348
+ * by element, which a reordered array fails for a set that did not change.
349
+ * Completion order must not be observable anywhere.
350
+ * 2. A FAILING PIECE DOES NOT TAKE OUT ITS SIBLINGS. One rejection records one
351
+ * missing key and the lane moves on; only an ABORT stops a lane.
352
+ * 3. NOTHING IS INGESTED AFTER TEARDOWN. The check between `render` and
353
+ * `ingest` is the same one the sequential loop had, and it matters more
354
+ * here because `renderLanes` renders can be in flight at once: pixels that
355
+ * land after `destroy()` are dropped BEFORE `ingest`, so no SkImage is ever
356
+ * created that no content object will release. A piece that got as far as
357
+ * `put` + `acquire` is pinned, and `finish`'s destroyed branch releases
358
+ * exactly those.
359
+ *
360
+ * A superseded epoch never WAITS for work it has not already started: `stopped`
361
+ * is checked at the top of every claim, so the abort takes effect after at most
362
+ * the pieces already inside `handle.render` — which are aborted through
363
+ * `state.abort.signal` and, on a backend that honours it, reject at once.
364
+ */
365
+ async function run(state) {
366
+ inFlight = state;
367
+ const items = state.plan.items;
368
+ const slots = new Array(items.length).fill(undefined);
369
+ const missing = [];
370
+ let claimed = 0;
371
+ let stopped = false;
372
+ const producedSoFar = () => {
373
+ const out = [];
374
+ for (const raster of slots)
375
+ if (raster !== undefined)
376
+ out.push(raster);
377
+ return out;
378
+ };
379
+ /**
380
+ * The part of a piece that is decided WITHOUT suspending: a cache hit, or a
381
+ * plan item for a page that does not exist. Returns undefined when it
382
+ * settled the piece, or the promise for the render it started.
383
+ *
384
+ * SPLIT OUT ON PURPOSE, and it is not a micro-optimisation. A lane that
385
+ * `await`s a cache hit yields a turn per hit, and the base grid is USUALLY
386
+ * all hits — six of them on a tiled letter page — so the first genuine
387
+ * render would no longer be issued synchronously from `settle()`. Nothing in
388
+ * production breaks either way, but "dispatch starts the first render before
389
+ * it returns" is a property the suite's injected clock relies on, and
390
+ * quietly giving it up would move the whole cadence a scheduler turn later
391
+ * for no benefit at all.
392
+ */
393
+ const claim = (item, slot) => {
394
+ const hit = cache.acquire(item.key);
395
+ if (hit !== undefined) {
396
+ emit({ type: 'cache-hit', key: item.key });
397
+ slots[slot] = hit;
398
+ return undefined;
399
+ }
400
+ const info = pageInfos[item.page];
401
+ if (info === undefined)
402
+ return undefined; // items only come from pageInfos
403
+ return renderOne(item, slot, info);
404
+ };
405
+ const renderOne = async (item, slot, info) => {
406
+ const startedAt = now();
407
+ let pixels;
408
+ try {
409
+ pixels = await handle.render({
410
+ page: item.page,
411
+ // Page-LOCAL rect: the planner works in LAYOUT space, but a backend
412
+ // renders a page and knows nothing about where the layout put it.
413
+ docRect: {
414
+ x: (item.docRect.x - info.pageRect.x) / info.layoutScale,
415
+ y: (item.docRect.y - info.pageRect.y) / info.layoutScale,
416
+ width: item.docRect.width / info.layoutScale,
417
+ height: item.docRect.height / info.layoutScale,
418
+ },
419
+ scale: item.scale * info.layoutScale,
420
+ // Never: the Skia layer draws the app's own annotations on top and
421
+ // PDF-embedded ones would double-draw.
422
+ annotations: false,
423
+ background: 'white',
424
+ }, state.abort.signal);
425
+ }
426
+ catch (error) {
427
+ missing.push(item.key);
428
+ // An abort stops the whole run; a genuine backend failure is this
429
+ // piece's problem alone and the other lanes keep going.
430
+ if (state.abort.signal.aborted) {
431
+ stopped = true;
432
+ return;
433
+ }
434
+ emit({ type: 'error', key: item.key, error: toPdfError(error) });
435
+ return;
436
+ }
437
+ if (destroyed) {
438
+ // Landed after teardown. NOT ingesting is the release: `pixels` is a
439
+ // plain buffer nothing native owns yet, so dropping it here is a
440
+ // collectable allocation — whereas ingesting it mints an SkImage that no
441
+ // content object exists to free.
442
+ stopped = true;
443
+ return;
444
+ }
445
+ // INGEST THROWS BY CONTRACT, and this try is what keeps that a piece's
446
+ // problem rather than the run's. `ingestRaster` raises 'backend-failure'
447
+ // whenever `Skia.Image.MakeImage` returns null — memory pressure, or a
448
+ // colour type the active Skia backend refuses — which is exactly the
449
+ // condition a high-zoom epoch is most likely to hit, and it lands AFTER
450
+ // the try that covers `handle.render`. Left to escape it rejects the lane,
451
+ // which is the one failure this whole structure must not have (see `lane`).
452
+ try {
453
+ emit({
454
+ type: 'raster',
455
+ key: item.key,
456
+ ms: now() - startedAt,
457
+ pixels: item.pixels.width * item.pixels.height,
458
+ backend: backendId,
459
+ });
460
+ // Measured with the hi-res clock, never `now()`: `now` is injectable so
461
+ // tests can drive a fake one, and 1 ms quantisation is exactly the wrong
462
+ // resolution for the term this exists to size.
463
+ const ingestStartedAt = monotonicNow();
464
+ const raster = ingest(pixels, {
465
+ key: item.key,
466
+ page: item.page,
467
+ role: item.role,
468
+ docRect: item.docRect,
469
+ scale: item.scale,
470
+ epoch: epoch + 1,
471
+ });
472
+ const ingestMs = monotonicNow() - ingestStartedAt;
473
+ // Emitted only when the BACKEND reported its half of the split. A
474
+ // backend that does not (the fake one, and therefore the whole vitest
475
+ // suite) sees no behaviour change at all — and an ingest number with no
476
+ // transport number beside it would be the same subtraction that made
477
+ // this measurement necessary.
478
+ const timings = pixels.timings;
479
+ if (timings !== undefined) {
480
+ emit({
481
+ type: 'raster-phases',
482
+ key: item.key,
483
+ backend: backendId,
484
+ bridgeMs: timings.bridgeMs,
485
+ decodeMs: timings.decodeMs,
486
+ ingestMs,
487
+ totalMs: timings.bridgeMs + timings.decodeMs + ingestMs,
488
+ payloadChars: timings.payloadChars,
489
+ byteLength: pixels.bytes.byteLength,
490
+ tight: timings.tight,
491
+ });
492
+ }
493
+ if (!cache.put(raster)) {
494
+ // Refused by the budget. Degrade: this piece simply does not exist and
495
+ // the base keeps drawing underneath it. `finish` arms a repair, so the
496
+ // refusal is transient — by the next evaluation the commit has released
497
+ // a whole retired grid and there is room.
498
+ missing.push(item.key);
499
+ return;
500
+ }
501
+ // Pin immediately. Without this, a later item of the SAME plan can evict
502
+ // an earlier one to make room for itself — and with lanes, a CONCURRENT
503
+ // one can too.
504
+ const owned = cache.acquire(item.key);
505
+ if (owned === undefined)
506
+ missing.push(item.key);
507
+ else
508
+ slots[slot] = owned;
509
+ }
510
+ catch (error) {
511
+ // Same shape as a backend failure: one missing key, coverage genuinely
512
+ // incomplete, and the repair re-plans the identical geometry-derived key.
513
+ missing.push(item.key);
514
+ emit({ type: 'error', key: item.key, error: toPdfError(error) });
515
+ return;
516
+ }
517
+ // Superseded and no longer useful: stop burning renders on it.
518
+ if (state.abort.signal.aborted &&
519
+ !stillWorthCommitting(state, producedSoFar()))
520
+ stopped = true;
521
+ };
522
+ /**
523
+ * A LANE MUST NEVER REJECT. That is the invariant this try exists to make
524
+ * true unconditionally, rather than leave as something you have to re-derive
525
+ * from every callee each time one of them changes.
526
+ *
527
+ * `Promise.all` rejects on the FIRST lane to fail and hands control straight
528
+ * back to `run`'s caller, which means two things at once, both bad and
529
+ * neither visible at the throw site:
530
+ *
531
+ * - `run` never reaches `inFlight = null` or `finish`, so the controller
532
+ * believes an abandoned run is still producing. Every later `dispatch`
533
+ * takes the in-flight branch and no epoch is ever committed again.
534
+ * - the SIBLING lanes keep running, and the pieces they pin with
535
+ * `cache.acquire` have lost the only code path that would release them.
536
+ *
537
+ * So a throw is recorded the way every other per-piece failure is: one
538
+ * missing key, coverage genuinely incomplete, the repair re-plans it. The
539
+ * inner guards in `renderOne` catch the two throws that are actually
540
+ * expected (the backend's and ingest's) and report them with more context;
541
+ * this catches everything else, including a host-supplied `RasterCache`.
542
+ */
543
+ const lane = async () => {
544
+ for (;;) {
545
+ if (stopped || destroyed)
546
+ return;
547
+ const slot = claimed++;
548
+ const item = items[slot];
549
+ if (item === undefined)
550
+ return;
551
+ try {
552
+ const started = claim(item, slot);
553
+ if (started !== undefined)
554
+ await started;
555
+ }
556
+ catch (error) {
557
+ // Deduped: `renderOne` may already have recorded this key before
558
+ // something after it threw, and a doubled entry would make the same
559
+ // hole look like a different one and reset the repair back-off.
560
+ if (!missing.includes(item.key))
561
+ missing.push(item.key);
562
+ emit({ type: 'error', key: item.key, error: toPdfError(error) });
563
+ }
564
+ }
565
+ };
566
+ const lanes = Math.max(1, Math.min(renderLanes, items.length));
567
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
568
+ inFlight = null;
569
+ finish(state, producedSoFar(), missing);
570
+ }
571
+ /**
572
+ * Do NOT discard a completed raster whose plan still satisfies the predicates
573
+ * against the newest viewport. Throwing away a finished 400ms render is the
574
+ * failure that leaves a user pinch-nudging and never seeing detail.
575
+ */
576
+ function stillWorthCommitting(state, produced) {
577
+ if (produced.length === 0)
578
+ return false;
579
+ if (latestViewport === null)
580
+ return true;
581
+ // Anti-blank beats freshness: a base for a page that has none outranks any
582
+ // staleness argument, because the alternative is drawing nothing at all.
583
+ if (produced.some(raster => raster.role === 'base' && !hasBase(raster.page)))
584
+ return true;
585
+ // committedAtMs is `now()` on purpose: this run has not been committed, and
586
+ // dating it in the past would let the staleness predicate reject work that
587
+ // has only just finished.
588
+ //
589
+ // The PLAN's coverage, deliberately, where `commit` records the PRODUCED
590
+ // coverage. The question here is "has the viewport moved past what this run
591
+ // was for", not "did this run come out whole": judging a partial run against
592
+ // its own holes would answer 'coverage' and throw away the pieces that DID
593
+ // land, when the right move is to mount them and re-plan the hole.
594
+ const asEpoch = {
595
+ detailScale: state.plan.detailScale,
596
+ pageRects: plannedPageRects(state.plan),
597
+ coveredRects: state.plan.coveredDocRect === null ? [] : [state.plan.coveredDocRect],
598
+ committedAtMs: now(),
599
+ };
600
+ return firedAgainst(asEpoch, state.viewport, latestViewport) === null;
601
+ }
602
+ function finish(state, produced, missing) {
603
+ const superseded = state.abort.signal.aborted || queued !== null;
604
+ if (destroyed) {
605
+ // Torn down mid-run. Nothing is mounted any more, so flushing here is safe
606
+ // and is the only thing that keeps a late-landing raster from leaking.
607
+ // NO EPOCH, deliberately, on either call: there is no renderer left to
608
+ // fence against, and an epoch here would strand these until a commit that
609
+ // is never coming.
610
+ for (const raster of produced)
611
+ cache.release(raster.key);
612
+ cache.flushRetired();
613
+ return;
614
+ }
615
+ if (produced.length > 0 &&
616
+ (!superseded || stillWorthCommitting(state, produced))) {
617
+ commit(state, produced, missing);
618
+ }
619
+ else {
620
+ // Dropped. The pins go back; the entries die at the next commit's flush,
621
+ // and until then they are still cache hits if the next plan wants them.
622
+ // Untagged: a dropped run mounted nothing, so no renderer ever saw these.
623
+ for (const raster of produced)
624
+ cache.release(raster.key);
625
+ }
626
+ if (queued !== null) {
627
+ const viewport = queued;
628
+ queued = null;
629
+ // Re-evaluate rather than replay: the queued plan was built for a viewport
630
+ // that may itself be stale, and the predicates may now be satisfied by
631
+ // what we just committed. This IS the next evaluation, so a hole does not
632
+ // additionally need a repair armed for it.
633
+ evaluate(viewport, false);
634
+ return;
635
+ }
636
+ scheduleRepair(state, missing);
637
+ }
638
+ /* ---------------------------------------------------------------- *
639
+ * Self-heal
640
+ * ---------------------------------------------------------------- */
641
+ /**
642
+ * THE HOLE THAT NEVER CLOSED.
643
+ *
644
+ * `commit` records coverage from what was PRODUCED, so an epoch that lost a
645
+ * piece leaves a genuine hole in `coveredRects` and the coverage predicate
646
+ * WOULD fire the instant it were consulted. The design's stated repair — "a
647
+ * hole re-fires 'coverage' on the next evaluation", with deliberately no retry
648
+ * timer — is sound only while an evaluation is guaranteed to arrive. At rest
649
+ * none is: every entry point to `evaluate` is host-driven (`hint`, `settle`),
650
+ * and a host whose viewport has stopped moving has nothing to report. So the
651
+ * machine published `settling: true`, stated that it knew it was incomplete,
652
+ * and stopped. On the phone that is a low-resolution rectangle in the middle of
653
+ * a sharp page that never sharpens, however long you look at it.
654
+ *
655
+ * This is NOT the retry timer the design rules out. It does not re-render a
656
+ * piece and it holds no reference to one: it arms exactly one re-EVALUATION
657
+ * against `latestViewport`, through the same `arm` seam the min-interval
658
+ * back-off already uses, and everything downstream — the predicates, the
659
+ * min-interval floor, `dispatch`'s identical-signature guard, the cache hits on
660
+ * every piece that DID land — behaves as it does for a host-driven settle. If
661
+ * the viewport has moved on, the evaluation plans for where the user is now and
662
+ * the stale hole was never worth repairing anyway.
663
+ *
664
+ * It also closes the BUDGET half of the same symptom without touching
665
+ * `cacheBudgetBytes` — see the note in `run` on `cache.put` refusal.
666
+ */
667
+ function scheduleRepair(state, missing) {
668
+ if (destroyed)
669
+ return;
670
+ if (missing.length === 0) {
671
+ // A whole epoch. Nothing outstanding, so the next hole starts its back-off
672
+ // from the beginning rather than inheriting this run's history.
673
+ repairSignature = null;
674
+ repairAttempts = 0;
675
+ return;
676
+ }
677
+ if (pending !== null) {
678
+ // An evaluation is already armed — a quiet timer from a hint, or the
679
+ // min-interval back-off. It will consult the coverage predicate against
680
+ // the newest viewport, which is the whole of what a repair does, and it is
681
+ // due sooner. Replacing it with ours would only postpone it.
682
+ return;
683
+ }
684
+ const signature = `${state.signature}#${[...missing].sort().join(',')}`;
685
+ if (signature !== repairSignature) {
686
+ repairSignature = signature;
687
+ repairAttempts = 0;
688
+ }
689
+ const inMs = Math.min(REPAIR_MAX_MS, REPAIR_BASE_MS * 2 ** repairAttempts);
690
+ repairAttempts += 1;
691
+ emit({
692
+ type: 'repair',
693
+ missing: missing.length,
694
+ inMs,
695
+ attempt: repairAttempts,
696
+ });
697
+ arm(inMs);
698
+ }
699
+ /* ---------------------------------------------------------------- *
700
+ * Epoch commit
701
+ * ---------------------------------------------------------------- */
702
+ /**
703
+ * May every one of `previous` be dropped now that `next` is mounted?
704
+ *
705
+ * Judged against the WHOLE of `next` — see `rectsCover`. The old rule asked
706
+ * whether each old raster sat inside ONE fresh raster, which a grid can never
707
+ * satisfy for a piece that straddles a seam: `retiring` became the entire
708
+ * previous grid on every epoch and the cache held two full grids through every
709
+ * transition.
710
+ *
711
+ * `next` is deliberately what was PRODUCED this run, not what was planned, so a
712
+ * grid that lost a piece to the budget cannot claim coverage. That is the
713
+ * no-blank guarantee: an old piece is released only once something mounted in
714
+ * the same commit paints every point of it.
715
+ */
716
+ function fullyCovers(next, previous) {
717
+ if (previous.length === 0)
718
+ return true;
719
+ if (next.length === 0)
720
+ return false;
721
+ const fresh = next.map(raster => raster.docRect);
722
+ return previous.every(old => rectsCover(fresh, old.docRect, CONTAINMENT_EPSILON));
723
+ }
724
+ function commit(state, produced, missing) {
725
+ epoch += 1;
726
+ const plannedPages = new Set(state.plan.items.map(item => item.page));
727
+ const detailPlannedPages = new Set(state.plan.items
728
+ .filter(item => item.role === 'detail')
729
+ .map(item => item.page));
730
+ /**
731
+ * TWO RELEASE LISTS, AND THE SPLIT IS THE WHOLE POINT.
732
+ *
733
+ * `releaseMounted` is rasters the OUTGOING content object was drawing. A
734
+ * renderer may still be holding them — on web it re-records the picture from
735
+ * the mounted tree several times before React commits this epoch — so these
736
+ * are released WITH the epoch, and the cache holds them behind its fence
737
+ * until the renderer has provably moved on (see `RasterCache`).
738
+ *
739
+ * `releaseFresh` is rasters this run produced and this commit then declined
740
+ * to mount. They were never in a content object, so no Skia node ever saw
741
+ * them and there is nothing to fence: they are released untagged and die at
742
+ * this flush, exactly as before. Tagging them too would hold a whole refused
743
+ * grid for an extra epoch for no reason at all.
744
+ */
745
+ const releaseMounted = [];
746
+ const releaseFresh = [];
747
+ const nextPages = content.pages.map(previous => {
748
+ // Last epoch's retiring set has now been mounted for a full epoch. This is
749
+ // the ONLY place it is dropped: retirement never owns a commit or a timer,
750
+ // it piggybacks on the commit that mounts the next epoch.
751
+ releaseMounted.push(...previous.retiring);
752
+ const freshBase = produced.filter(r => r.page === previous.page && r.role === 'base');
753
+ const freshDetail = produced.filter(r => r.page === previous.page && r.role === 'detail');
754
+ let base = previous.base;
755
+ if (freshBase.length > 0) {
756
+ // Every base piece this run touched was pinned by `run`, and content only
757
+ // ever holds ONE reference per slot — so whichever set loses below has to
758
+ // give its pins back. Skipping that leaks a refcount no flush can clear.
759
+ const unchanged = freshBase.length === base.length &&
760
+ freshBase.every((raster, index) => raster === base[index]);
761
+ if (unchanged) {
762
+ // All cache hits, same grid: keep the ARRAY IDENTITY so an untouched
763
+ // page can still return its previous object below. These ARE the
764
+ // mounted objects — the run took a second reference on each and this
765
+ // gives that one back, so the refcount never reaches zero and the
766
+ // fence is not consulted either way.
767
+ releaseMounted.push(...freshBase);
768
+ }
769
+ else if (fullyCovers(freshBase, base)) {
770
+ releaseMounted.push(...base);
771
+ base = Object.freeze(freshBase);
772
+ }
773
+ else {
774
+ // The anti-blank invariant, restated for a set: never EMPTIED once
775
+ // populated — and, one step stronger, never NARROWED. A fresh grid that
776
+ // lost a piece (budget refusal, backend error) would leave a hole in the
777
+ // one layer guaranteed to be drawable, so it is dropped and the covering
778
+ // set we already have stands. Those keys are already in `missing`, so
779
+ // this epoch reports `settling` and the next one re-plans them.
780
+ releaseFresh.push(...freshBase);
781
+ }
782
+ }
783
+ let detail = previous.detail;
784
+ let retiring = EMPTY_RASTERS;
785
+ const rotating = freshDetail.length > 0 ||
786
+ // Planned but deliberately detail-free (zoomed back out), or the page
787
+ // has left the plan's window entirely: either way the old detail is dead
788
+ // weight. A plan that WANTED detail but produced none keeps what it has
789
+ // rather than blanking.
790
+ (plannedPages.has(previous.page) &&
791
+ !detailPlannedPages.has(previous.page)) ||
792
+ !plannedPages.has(previous.page);
793
+ if (rotating) {
794
+ detail =
795
+ freshDetail.length > 0 ? Object.freeze(freshDetail) : EMPTY_RASTERS;
796
+ if (policy.retainPreviousDetail ||
797
+ !fullyCovers(detail, previous.detail)) {
798
+ retiring = previous.detail;
799
+ }
800
+ else {
801
+ releaseMounted.push(...previous.detail);
802
+ }
803
+ }
804
+ // A page far outside the window loses its base to the cache. "Base is
805
+ // never emptied" is a promise about the pages you are LOOKING at; holding
806
+ // 500 of them is how a long document runs out of memory.
807
+ const nearWindow = Array.from(plannedPages).some(p => Math.abs(p - previous.page) <= BASE_PAGE_RADIUS);
808
+ if (base.length > 0 && !nearWindow) {
809
+ releaseMounted.push(...base);
810
+ base = EMPTY_RASTERS;
811
+ }
812
+ if (base === previous.base &&
813
+ detail === previous.detail &&
814
+ retiring === previous.retiring &&
815
+ previous.retiring.length === 0) {
816
+ return previous; // untouched page keeps its object; only content identity matters
817
+ }
818
+ return Object.freeze({
819
+ page: previous.page,
820
+ pageRect: previous.pageRect,
821
+ base,
822
+ detail,
823
+ retiring,
824
+ });
825
+ });
826
+ const committedKeys = new Set(produced.map(raster => raster.key));
827
+ const settling = missing.length > 0 ||
828
+ queued !== null ||
829
+ state.plan.items.some(i => !committedKeys.has(i.key));
830
+ content = Object.freeze({ pages: Object.freeze(nextPages), epoch, settling });
831
+ // WITH THE EPOCH, and that is the fix for the disposed-SkImage draw. These
832
+ // rasters have just left `content`, but `content` is not what draws — the
833
+ // renderer is, and it has not been told yet (`notify` is three lines below,
834
+ // and React commits later still). The epoch is what lets the cache hold them
835
+ // until the renderer has demonstrably moved on.
836
+ for (const raster of releaseMounted)
837
+ cache.release(raster.key, epoch);
838
+ // Untagged: never mounted, so nothing can be drawing them. See the split.
839
+ for (const raster of releaseFresh)
840
+ cache.release(raster.key);
841
+ cache.setLiveWindow(Array.from(plannedPages));
842
+ // Piggybacked on the commit that mounts this epoch — never its own timer —
843
+ // and fenced ON that epoch, so what it frees is the PREVIOUS commit's
844
+ // leavings rather than this one's. Flushing what we released above is a delay
845
+ // of zero, which is what drew corpses.
846
+ cache.flushRetired(epoch);
847
+ /**
848
+ * WHAT THIS EPOCH COVERS IS WHAT IT PRODUCED, never what it planned.
849
+ *
850
+ * The plan's `coveredDocRect` is an intention. Recording it would have the
851
+ * epoch claim coverage of a piece that errored or was refused by the budget,
852
+ * and the coverage predicate would then never fire for that hole — it would
853
+ * sit there until some unrelated predicate happened to trigger. Tiling makes
854
+ * that near-certain rather than rare: there are 8-32 chances per epoch now.
855
+ *
856
+ * There is deliberately no retry timer, and there still is not one: a hole
857
+ * re-fires 'coverage' on the next EVALUATION — the predicate is an exact
858
+ * union test over each page's visible part, so an INTERIOR hole fires too
859
+ * (see `EpochState.pageRects` and `coveredRects`) — and the re-plan asks for
860
+ * the SAME key, because keys are derived from geometry and not from the
861
+ * epoch, so every piece that landed is a cache hit and only the hole costs a
862
+ * render.
863
+ *
864
+ * WHAT CHANGED: that next evaluation used to be somebody else's problem.
865
+ * Every entry point is host-driven, and a host at rest produces none — so on
866
+ * a stationary viewport the hole was permanent and `settling: true` was the
867
+ * controller announcing it. `scheduleRepair` arms one re-evaluation when this
868
+ * run ends with `missing` non-empty. `missing` still feeds `settling`, which
869
+ * remains a report and not a mechanism.
870
+ */
871
+ const coveredRects = [];
872
+ if (state.plan.baseCoveredDocRect !== null)
873
+ coveredRects.push(state.plan.baseCoveredDocRect);
874
+ for (const raster of produced)
875
+ if (raster.role === 'detail')
876
+ coveredRects.push(raster.docRect);
877
+ epochState = {
878
+ detailScale: state.plan.detailScale,
879
+ pageRects: plannedPageRects(state.plan),
880
+ coveredRects,
881
+ committedAtMs: now(),
882
+ };
883
+ rasterViewport = state.viewport;
884
+ committedSignature = state.signature;
885
+ plannedSignature = state.signature;
886
+ emit({
887
+ type: 'epoch',
888
+ epoch,
889
+ items: produced.length,
890
+ reason: state.reason,
891
+ missing: missing.length,
892
+ });
893
+ notify();
894
+ }
895
+ /* ---------------------------------------------------------------- *
896
+ * Public surface
897
+ * ---------------------------------------------------------------- */
898
+ return {
899
+ hint(viewport) {
900
+ if (destroyed)
901
+ return;
902
+ // Still worth recording under suppression: an in-flight result is judged
903
+ // against the newest viewport, gesture or no gesture.
904
+ latestViewport = viewport;
905
+ if (suppressed) {
906
+ emit({ type: 'skip', reason: 'suppressed' });
907
+ return;
908
+ }
909
+ const zoomed = lastSeenScale !== null && viewport.scale !== lastSeenScale;
910
+ lastSeenScale = viewport.scale;
911
+ const signature = signatureOf(buildPlan(viewport));
912
+ if (signature === plannedSignature) {
913
+ // A no-op, INCLUDING the timer: restarting it would let a stream of
914
+ // identical hints postpone a raster forever. Any state change here costs
915
+ // a full re-record of the consumer's entire scene.
916
+ emit({ type: 'skip', reason: 'identical-plan' });
917
+ return;
918
+ }
919
+ plannedSignature = signature;
920
+ // maxStaleMs: under continued input the quiet timer never gets to fire, so
921
+ // force one evaluation through.
922
+ if (epochState !== null &&
923
+ now() - epochState.committedAtMs >= policy.maxStaleMs) {
924
+ evaluate(viewport, true);
925
+ return;
926
+ }
927
+ arm(zoomed ? policy.quietZoomMs : policy.quietPanMs);
928
+ },
929
+ settle(viewport) {
930
+ if (destroyed)
931
+ return;
932
+ // Deliberately NOT gated on `suppressed`: settle is the discrete path, and
933
+ // a host that forgets resume() must still get its raster.
934
+ cancelPending();
935
+ lastSeenScale = viewport.scale;
936
+ evaluate(viewport, false);
937
+ },
938
+ suppress() {
939
+ suppressed = true;
940
+ cancelPending();
941
+ },
942
+ resume() {
943
+ suppressed = false;
944
+ },
945
+ getContent() {
946
+ return content;
947
+ },
948
+ subscribe(listener) {
949
+ listeners.add(listener);
950
+ return () => {
951
+ listeners.delete(listener);
952
+ };
953
+ },
954
+ notePresented(reportedEpoch) {
955
+ if (destroyed)
956
+ return;
957
+ if (!Number.isFinite(reportedEpoch))
958
+ return;
959
+ // CLAMPED to what has actually been published. A host that reports an epoch
960
+ // we never committed would advance the fence past reality, which is the one
961
+ // way this seam could reintroduce the bug it exists to close.
962
+ cache.notePresented?.(Math.min(reportedEpoch, epoch));
963
+ },
964
+ destroy() {
965
+ if (destroyed)
966
+ return;
967
+ destroyed = true;
968
+ cancelPending();
969
+ queued = null;
970
+ inFlight?.abort.abort();
971
+ inFlight = null;
972
+ unsubscribeEvictions?.();
973
+ // No page is live any more, so nothing is protected from the flush below.
974
+ // (The cache assumes one controller; a shared one would need a scoped
975
+ // window instead.)
976
+ cache.setLiveWindow([]);
977
+ for (const page of content.pages) {
978
+ for (const raster of page.base)
979
+ cache.release(raster.key);
980
+ for (const raster of page.detail)
981
+ cache.release(raster.key);
982
+ for (const raster of page.retiring)
983
+ cache.release(raster.key);
984
+ }
985
+ content = Object.freeze({
986
+ pages: Object.freeze(content.pages.map(page => Object.freeze({
987
+ page: page.page,
988
+ pageRect: page.pageRect,
989
+ base: EMPTY_RASTERS,
990
+ detail: EMPTY_RASTERS,
991
+ retiring: EMPTY_RASTERS,
992
+ }))),
993
+ epoch,
994
+ settling: false,
995
+ });
996
+ // Safe here, and unfenced on purpose: content no longer references
997
+ // anything, so nothing freed is still mounted.
998
+ cache.flushRetired();
999
+ listeners.clear();
1000
+ },
1001
+ };
1002
+ }