@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.
- package/PdfCanvas.podspec +80 -0
- package/README.md +1391 -0
- package/android/build.gradle +175 -0
- package/android/consumer-rules.pro +3 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +2 -0
- package/android/gradlew +248 -0
- package/android/gradlew.bat +98 -0
- package/android/settings.gradle +34 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +226 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +239 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +251 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +221 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +783 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +1388 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +98 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +324 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +775 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +20 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +558 -0
- package/android/src/jsi/cpp/CMakeLists.txt +33 -0
- package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +469 -0
- package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +140 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +45 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +564 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +33 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +277 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +214 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +30 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +234 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +47 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +143 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +95 -0
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +626 -0
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasPackage.java +33 -0
- package/android/tools/compile-gate.sh +289 -0
- package/dist/cache.d.ts +161 -0
- package/dist/cache.js +389 -0
- package/dist/clock.d.ts +21 -0
- package/dist/clock.js +31 -0
- package/dist/controller.d.ts +131 -0
- package/dist/controller.js +1002 -0
- package/dist/geometry.d.ts +96 -0
- package/dist/geometry.js +248 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +50 -0
- package/dist/index.native.d.ts +21 -0
- package/dist/index.native.js +23 -0
- package/dist/index.web.d.ts +24 -0
- package/dist/index.web.js +26 -0
- package/dist/layout.d.ts +78 -0
- package/dist/layout.js +152 -0
- package/dist/peer-check.d.ts +83 -0
- package/dist/peer-check.js +183 -0
- package/dist/planner.d.ts +35 -0
- package/dist/planner.js +530 -0
- package/dist/policy.d.ts +14 -0
- package/dist/policy.js +168 -0
- package/dist/rasterizer/android.d.ts +121 -0
- package/dist/rasterizer/android.js +126 -0
- package/dist/rasterizer/buffer.d.ts +18 -0
- package/dist/rasterizer/buffer.js +18 -0
- package/dist/rasterizer/fake.d.ts +92 -0
- package/dist/rasterizer/fake.js +433 -0
- package/dist/rasterizer/index.d.ts +38 -0
- package/dist/rasterizer/index.js +69 -0
- package/dist/rasterizer/index.native.d.ts +19 -0
- package/dist/rasterizer/index.native.js +80 -0
- package/dist/rasterizer/index.web.d.ts +63 -0
- package/dist/rasterizer/index.web.js +75 -0
- package/dist/rasterizer/ingest.d.ts +180 -0
- package/dist/rasterizer/ingest.js +307 -0
- package/dist/rasterizer/ios.d.ts +84 -0
- package/dist/rasterizer/ios.js +155 -0
- package/dist/rasterizer/native-bridge.d.ts +261 -0
- package/dist/rasterizer/native-bridge.js +413 -0
- package/dist/rasterizer/web/client.d.ts +84 -0
- package/dist/rasterizer/web/client.js +359 -0
- package/dist/rasterizer/web/engine.d.ts +69 -0
- package/dist/rasterizer/web/engine.js +308 -0
- package/dist/rasterizer/web/index.d.ts +26 -0
- package/dist/rasterizer/web/index.js +21 -0
- package/dist/rasterizer/web/pdfium.d.ts +213 -0
- package/dist/rasterizer/web/pdfium.js +170 -0
- package/dist/rasterizer/web/protocol.d.ts +138 -0
- package/dist/rasterizer/web/protocol.js +37 -0
- package/dist/rasterizer/web/session.d.ts +106 -0
- package/dist/rasterizer/web/session.js +413 -0
- package/dist/rasterizer/web/worker.d.ts +44 -0
- package/dist/rasterizer/web/worker.js +76 -0
- package/dist/react/PdfContentView.d.ts +98 -0
- package/dist/react/PdfContentView.js +189 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +3 -0
- package/dist/react/usePdfDocument.d.ts +134 -0
- package/dist/react/usePdfDocument.js +275 -0
- package/dist/react/usePdfLayer.d.ts +94 -0
- package/dist/react/usePdfLayer.js +388 -0
- package/dist/skia/index.d.ts +26 -0
- package/dist/skia/index.js +25 -0
- package/dist/skia/index.native.d.ts +14 -0
- package/dist/skia/index.native.js +16 -0
- package/dist/skia/index.web.d.ts +12 -0
- package/dist/skia/index.web.js +14 -0
- package/dist/skia/resolve.native.d.ts +18 -0
- package/dist/skia/resolve.native.js +22 -0
- package/dist/skia/resolve.web.d.ts +41 -0
- package/dist/skia/resolve.web.js +62 -0
- package/dist/skia/skia-api.d.ts +94 -0
- package/dist/skia/skia-api.js +113 -0
- package/dist/testing/index.d.ts +27 -0
- package/dist/testing/index.js +28 -0
- package/dist/testing/scenes.d.ts +100 -0
- package/dist/testing/scenes.js +267 -0
- package/dist/types.d.ts +611 -0
- package/dist/types.js +21 -0
- package/dist/web-init.d.ts +68 -0
- package/dist/web-init.js +64 -0
- package/ios/Package.swift +58 -0
- package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +743 -0
- package/ios/Sources/PdfCanvasBridge/include/PdfCanvasModule.h +36 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +38 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +389 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +57 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +44 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +82 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +37 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +158 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +80 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +65 -0
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +548 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +48 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +26 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +122 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +80 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +79 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +129 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +100 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +65 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +111 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +85 -0
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +163 -0
- package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +297 -0
- package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +140 -0
- package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +358 -0
- package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +262 -0
- package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +173 -0
- package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +114 -0
- package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +158 -0
- package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +544 -0
- package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +287 -0
- package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +396 -0
- package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +639 -0
- package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +179 -0
- package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +283 -0
- package/package.json +144 -0
- package/react-native.config.js +40 -0
- package/scripts/check-skia-peer.mjs +86 -0
- 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
|
+
}
|