@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
package/dist/planner.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two pure decisions: WHAT to raster (`planRasters`) and WHEN
|
|
3
|
+
* (`shouldReraster`). No React, no Skia, no clock, no async — `nowMs` is
|
|
4
|
+
* passed in so the cadence is testable without fake timers.
|
|
5
|
+
*/
|
|
6
|
+
import { fitScale, inflateRect, isEmptyRect, rectContains, rectIntersection, rectUnion, rectsCover, rectsEqual, scaleToPixels, snapRectToDevicePixels, } from './geometry.js';
|
|
7
|
+
/**
|
|
8
|
+
* Device pixels adjacent detail pieces share along an interior seam. Two is
|
|
9
|
+
* enough: the seam is already covered by the outward snap on both sides, and
|
|
10
|
+
* this only has to absorb the half-pixel a bilinear sample reaches across.
|
|
11
|
+
*/
|
|
12
|
+
const PIECE_OVERLAP_PX = 2;
|
|
13
|
+
/**
|
|
14
|
+
* Headroom subtracted from the piece cap when choosing a piece count.
|
|
15
|
+
* A piece is grown after it is sized — by the overlap on its interior edges
|
|
16
|
+
* and by up to a pixel of outward snapping on each edge — so dividing by the
|
|
17
|
+
* raw limit can land a piece one pixel over it, which is not a soft failure
|
|
18
|
+
* when the binding cap is the texture limit: the upload fails and the piece
|
|
19
|
+
* draws nothing.
|
|
20
|
+
*/
|
|
21
|
+
const PIECE_SLACK_PX = 8;
|
|
22
|
+
/** Below this much travel on an axis, treat it as no travel at all. */
|
|
23
|
+
const MOTION_EPSILON_PX = 0.5;
|
|
24
|
+
/**
|
|
25
|
+
* Relative slack when comparing two scales for "close enough to equal".
|
|
26
|
+
* Scales arrive quantized to 6 significant digits, so anything tighter than
|
|
27
|
+
* this compares float noise.
|
|
28
|
+
*/
|
|
29
|
+
const SCALE_EPSILON = 1e-6;
|
|
30
|
+
/**
|
|
31
|
+
* Six significant digits, RELATIVE. An absolute grid cannot work across the
|
|
32
|
+
* range in play: 1e-4 is 10% of a plotter sheet's fit scale and invisible at
|
|
33
|
+
* scale 8. The point is only to stop the same viewport, derived twice through
|
|
34
|
+
* different float paths, from producing two keys and re-rendering identical
|
|
35
|
+
* bytes.
|
|
36
|
+
*/
|
|
37
|
+
function quantizeScale(scale) {
|
|
38
|
+
if (!(scale > 0))
|
|
39
|
+
return 0;
|
|
40
|
+
return Number(scale.toPrecision(6));
|
|
41
|
+
}
|
|
42
|
+
function quantize(v) {
|
|
43
|
+
return Math.round(v * 1000) / 1000;
|
|
44
|
+
}
|
|
45
|
+
function rectKey(r) {
|
|
46
|
+
return (`${quantize(r.x)},${quantize(r.y)}` +
|
|
47
|
+
`,${quantize(r.width)},${quantize(r.height)}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Stable and collision-free: page, role, rect and scale together determine the
|
|
51
|
+
* BYTES, so two epochs that want the same pixels produce the same key and the
|
|
52
|
+
* second is a cache hit. The epoch number is deliberately absent — including
|
|
53
|
+
* it would make every key unique and defeat the cache entirely.
|
|
54
|
+
*/
|
|
55
|
+
function itemKey(page, role, rect, scale) {
|
|
56
|
+
return `p${page}|${role}|${rectKey(rect)}|s${quantizeScale(scale)}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ceiling on the pieces one axis may be divided into.
|
|
60
|
+
*
|
|
61
|
+
* The two caps below are consumer-settable numbers, and `ceil(extent / cap)` has
|
|
62
|
+
* no upper bound: a cap of 1 asks for 1755 columns and 3798 rows of a phone's
|
|
63
|
+
* detail target, which is 6.6 MILLION plan items and an out-of-memory crash on
|
|
64
|
+
* the settle path. 32 is far past anything useful — a 24 MP epoch at a 512 px cap
|
|
65
|
+
* needs 10 per axis — so a legitimate policy never reaches it, and a nonsense one
|
|
66
|
+
* degrades to a slow epoch instead of taking the process down.
|
|
67
|
+
*
|
|
68
|
+
* A piece may therefore exceed `maxPieceDimension`, which is a MEMORY policy and
|
|
69
|
+
* soft. It may never exceed `maxTextureDimension`: see `axisPieceCount`.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_PIECES_PER_AXIS = 32;
|
|
72
|
+
/**
|
|
73
|
+
* The largest pixel dimension a piece may be sized to, on either axis.
|
|
74
|
+
*
|
|
75
|
+
* TWO CAPS, DELIBERATELY NOT ONE. `maxTextureDimension` is a HARDWARE ceiling —
|
|
76
|
+
* exceed it and the texture upload fails and the piece draws nothing.
|
|
77
|
+
* `maxPieceDimension` is a MEMORY policy: nothing breaks when a piece is bigger,
|
|
78
|
+
* but the whole-flow transient peak scales with the largest single raster in
|
|
79
|
+
* flight (platform bitmap + transport + `Uint8Array` + `SkData` all live at
|
|
80
|
+
* once), so bounding one piece bounds the peak. The effective cap is the smaller
|
|
81
|
+
* of the two, less the slack every piece is grown by after it is sized.
|
|
82
|
+
*/
|
|
83
|
+
function usablePieceDimension(policy) {
|
|
84
|
+
const cap = Math.min(policy.maxPieceDimension, policy.maxTextureDimension);
|
|
85
|
+
return Math.max(1, cap - PIECE_SLACK_PX);
|
|
86
|
+
}
|
|
87
|
+
/** The same, for the hardware ceiling alone — the count that MUST be honoured. */
|
|
88
|
+
function usableTextureDimension(policy) {
|
|
89
|
+
return Math.max(1, policy.maxTextureDimension - PIECE_SLACK_PX);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How many pieces one axis of `extent` device pixels is divided into.
|
|
93
|
+
*
|
|
94
|
+
* The hardware count wins outright: past `maxTextureDimension` the upload fails
|
|
95
|
+
* and the piece draws NOTHING, so `MAX_PIECES_PER_AXIS` may not clamp below it.
|
|
96
|
+
* The memory count is what the clamp applies to, because exceeding
|
|
97
|
+
* `maxPieceDimension` only costs a bigger transient.
|
|
98
|
+
*/
|
|
99
|
+
function axisPieceCount(extent, policy) {
|
|
100
|
+
const required = Math.max(1, Math.ceil(extent / usableTextureDimension(policy)));
|
|
101
|
+
const wanted = Math.max(1, Math.ceil(extent / usablePieceDimension(policy)));
|
|
102
|
+
const count = Math.max(required, Math.min(wanted, MAX_PIECES_PER_AXIS));
|
|
103
|
+
// A non-finite policy field (an undefined `maxPieceDimension` from a hand-built
|
|
104
|
+
// RasterPolicy, say) makes every count above NaN, and `col < NaN` is false — so
|
|
105
|
+
// the grid loop would emit NOTHING and the page would be blank. Degrade to one
|
|
106
|
+
// whole piece instead: that is what this code did before it could tile, and a
|
|
107
|
+
// blank page is the one outcome the package never accepts.
|
|
108
|
+
return Number.isFinite(count) ? count : 1;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Bounding rect of a covered set, or null for a set that covers nothing.
|
|
112
|
+
*
|
|
113
|
+
* The BOUND is what the look-ahead predicate needs — padding ahead of the motion
|
|
114
|
+
* against padding behind it is a question about one rect. Coverage itself is
|
|
115
|
+
* asked of the SET (`rectsCover`), because a bound cannot express a hole.
|
|
116
|
+
*/
|
|
117
|
+
function boundsOf(rects) {
|
|
118
|
+
let bounds = null;
|
|
119
|
+
for (const rect of rects) {
|
|
120
|
+
if (isEmptyRect(rect))
|
|
121
|
+
continue;
|
|
122
|
+
bounds = bounds === null ? { ...rect } : rectUnion(bounds, rect);
|
|
123
|
+
}
|
|
124
|
+
return bounds;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `pages` and `pageRects` are parallel arrays; `visiblePages` holds page
|
|
128
|
+
* indices. They normally coincide, but a host that windows its page array does
|
|
129
|
+
* not have that luxury, so fall back to a search on `.index`.
|
|
130
|
+
*/
|
|
131
|
+
function positionOf(pages, index) {
|
|
132
|
+
const direct = pages[index];
|
|
133
|
+
if (direct && direct.index === index)
|
|
134
|
+
return index;
|
|
135
|
+
return pages.findIndex(p => p.index === index);
|
|
136
|
+
}
|
|
137
|
+
export function planRasters(args) {
|
|
138
|
+
const { viewport, pages, pageRects, visiblePages, policy } = args;
|
|
139
|
+
const items = [];
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const push = (item) => {
|
|
142
|
+
if (seen.has(item.key))
|
|
143
|
+
return;
|
|
144
|
+
seen.add(item.key);
|
|
145
|
+
items.push(item);
|
|
146
|
+
};
|
|
147
|
+
// The host never states the viewport in pixels. It is exactly the visible
|
|
148
|
+
// rect at the current scale, and deriving it here keeps the two consistent
|
|
149
|
+
// by construction — a separately-supplied size could disagree, and the
|
|
150
|
+
// disagreement would show up as a base raster at the wrong fit scale.
|
|
151
|
+
const viewportPixels = {
|
|
152
|
+
width: viewport.visibleDocRect.width * viewport.scale,
|
|
153
|
+
height: viewport.visibleDocRect.height * viewport.scale,
|
|
154
|
+
};
|
|
155
|
+
const inflated = inflateRect(viewport.visibleDocRect, policy.detailOverflow, policy.detailOverflow);
|
|
156
|
+
const targets = [];
|
|
157
|
+
/** Base scale actually planned per page, for the base-is-enough test below. */
|
|
158
|
+
const baseScaleByPage = new Map();
|
|
159
|
+
for (const pageIndex of visiblePages) {
|
|
160
|
+
const position = positionOf(pages, pageIndex);
|
|
161
|
+
if (position < 0)
|
|
162
|
+
continue;
|
|
163
|
+
// PageGeometry is read only to confirm the page exists: its width/height
|
|
164
|
+
// are ALREADY post-rotation, so a /Rotate 90 page is simply a landscape
|
|
165
|
+
// page here and needs no special handling anywhere below.
|
|
166
|
+
const geometry = pages[position];
|
|
167
|
+
const pageRect = pageRects[position];
|
|
168
|
+
if (!geometry || !pageRect || isEmptyRect(pageRect))
|
|
169
|
+
continue;
|
|
170
|
+
// Emitted unconditionally. "Needs one" is decided downstream by key
|
|
171
|
+
// identity: a base already in the cache has this exact key, so it is a
|
|
172
|
+
// cache hit and never re-renders. That is the whole reason the key is
|
|
173
|
+
// derived from geometry rather than from the epoch.
|
|
174
|
+
const baseScale = planBasePieces(pageIndex, pageRect, viewportPixels, policy, push);
|
|
175
|
+
if (baseScale > 0)
|
|
176
|
+
baseScaleByPage.set(pageIndex, baseScale);
|
|
177
|
+
const target = rectIntersection(inflated, pageRect);
|
|
178
|
+
if (target && !isEmptyRect(target)) {
|
|
179
|
+
targets.push({ page: pageIndex, rect: target });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const detailScale = resolveDetailScale(targets, viewport.scale, policy);
|
|
183
|
+
/**
|
|
184
|
+
* Rects that are adequately covered by a base raster alone.
|
|
185
|
+
*
|
|
186
|
+
* A page whose base is already at least as dense as the detail would be gets
|
|
187
|
+
* NO detail: it would be the same pixels at the same resolution, for double
|
|
188
|
+
* the raster time and double the resident bytes. At fit zoom that is every
|
|
189
|
+
* page, which on a continuous layout is the difference between one whole-page
|
|
190
|
+
* raster per page and two.
|
|
191
|
+
*
|
|
192
|
+
* These rects still count as COVERED, and that is load-bearing rather than
|
|
193
|
+
* bookkeeping: `shouldReraster` treats an empty covered set as "nothing is
|
|
194
|
+
* on screen" and fires 'coverage' unconditionally, so reporting nothing here
|
|
195
|
+
* would make every settle at fit zoom dispatch an all-cache-hits run and
|
|
196
|
+
* commit a redundant epoch — a full re-record of the consumer's scene for no
|
|
197
|
+
* new pixels. They are reported to the controller as
|
|
198
|
+
* `RasterPlan.baseCoveredDocRect`, separately from the detail items, because
|
|
199
|
+
* the controller records coverage from what it PRODUCED and no produced raster
|
|
200
|
+
* accounts for this half.
|
|
201
|
+
*/
|
|
202
|
+
let baseCoveredRect = null;
|
|
203
|
+
let sufficientBaseScale = Number.POSITIVE_INFINITY;
|
|
204
|
+
if (detailScale > 0) {
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
const baseScale = baseScaleByPage.get(target.page) ?? 0;
|
|
207
|
+
if (baseScale > 0 && detailScale <= baseScale * (1 + SCALE_EPSILON)) {
|
|
208
|
+
baseCoveredRect =
|
|
209
|
+
baseCoveredRect === null
|
|
210
|
+
? { ...target.rect }
|
|
211
|
+
: rectUnion(baseCoveredRect, target.rect);
|
|
212
|
+
sufficientBaseScale = Math.min(sufficientBaseScale, baseScale);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
planDetailPieces(target.page, target.rect, detailScale, policy, push);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const coveredRects = baseCoveredRect === null ? [] : [{ ...baseCoveredRect }];
|
|
219
|
+
let emittedDetail = false;
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if (item.role !== 'detail')
|
|
222
|
+
continue;
|
|
223
|
+
emittedDetail = true;
|
|
224
|
+
coveredRects.push(item.docRect);
|
|
225
|
+
}
|
|
226
|
+
const coveredDocRect = boundsOf(coveredRects);
|
|
227
|
+
/**
|
|
228
|
+
* The scale the sharpness predicate compares against must describe what is
|
|
229
|
+
* actually ON SCREEN. When every page fell back to its base, that is the base
|
|
230
|
+
* scale — so zooming in past the band still fires and plans real detail.
|
|
231
|
+
*/
|
|
232
|
+
const effectiveDetailScale = emittedDetail
|
|
233
|
+
? detailScale
|
|
234
|
+
: Number.isFinite(sufficientBaseScale)
|
|
235
|
+
? sufficientBaseScale
|
|
236
|
+
: 0;
|
|
237
|
+
return {
|
|
238
|
+
items,
|
|
239
|
+
detailScale: effectiveDetailScale,
|
|
240
|
+
coveredDocRect,
|
|
241
|
+
baseCoveredDocRect: baseCoveredRect,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The whole page at fit scale, as a grid of pieces whose union is EXACTLY the
|
|
246
|
+
* page rect. Returns the scale the pieces were planned at, or 0 for a page that
|
|
247
|
+
* cannot be based at all.
|
|
248
|
+
*
|
|
249
|
+
* WHY THE BASE IS TILED. It is the biggest single raster the package ever asks
|
|
250
|
+
* for: the base is allowed up to `baseMaxPixels` (4 MP = 15.26 MiB of pixels),
|
|
251
|
+
* so an un-tiled base's transient peak is roughly 4.8x that of a whole
|
|
252
|
+
* 1024-tiled detail epoch — the base was the largest remaining hole after detail
|
|
253
|
+
* was tiled. It also removes a latent blank-page bug: `baseMaxPixels` caps AREA,
|
|
254
|
+
* so a long thin sheet could clear it while overrunning `maxTextureDimension` on
|
|
255
|
+
* one axis, and that piece silently draws nothing.
|
|
256
|
+
*
|
|
257
|
+
* THE GRID IS CUT ON THE DEVICE-PIXEL GRID, not in doc space, which is why this
|
|
258
|
+
* does not use `snapRectToDevicePixels` the way the detail path does. Interior
|
|
259
|
+
* boundaries are whole pixel columns/rows and are converted back to doc space by
|
|
260
|
+
* dividing by `scale`; the OUTER edges are pinned to the page rect itself. So
|
|
261
|
+
* every piece's declared pixel size is exactly `round(docRect * scale)` — what a
|
|
262
|
+
* backend allocates — with no snapping and no drift, the union is the page rect
|
|
263
|
+
* to the last bit, and a 1x1 grid degenerates to precisely the single whole-page
|
|
264
|
+
* item this replaced. Snapping instead would push the outer edges OUTSIDE the
|
|
265
|
+
* page rect, and the base's rect is the one rect that must equal `pageRect`.
|
|
266
|
+
*/
|
|
267
|
+
function planBasePieces(page, pageRect, viewportPixels, policy, push) {
|
|
268
|
+
const fit = fitScale({ width: pageRect.width, height: pageRect.height }, viewportPixels);
|
|
269
|
+
if (!(fit > 0))
|
|
270
|
+
return 0;
|
|
271
|
+
let scale = fit * policy.baseQuality;
|
|
272
|
+
const area = pageRect.width * pageRect.height;
|
|
273
|
+
if (area * scale * scale > policy.baseMaxPixels) {
|
|
274
|
+
scale = Math.sqrt(policy.baseMaxPixels / area);
|
|
275
|
+
}
|
|
276
|
+
scale = quantizeScale(scale);
|
|
277
|
+
if (!(scale > 0))
|
|
278
|
+
return 0;
|
|
279
|
+
// Pixels are ROUNDED, not snapped: the rect is the page rect exactly (there is
|
|
280
|
+
// nothing outside it to expand into), and rounding is what makes the size —
|
|
281
|
+
// and therefore the key — immune to the last-bit drift in deriving the same
|
|
282
|
+
// fit scale twice.
|
|
283
|
+
const total = {
|
|
284
|
+
width: Math.max(1, Math.round(pageRect.width * scale)),
|
|
285
|
+
height: Math.max(1, Math.round(pageRect.height * scale)),
|
|
286
|
+
};
|
|
287
|
+
const cols = axisPieceCount(total.width, policy);
|
|
288
|
+
const rows = axisPieceCount(total.height, policy);
|
|
289
|
+
const outerRight = pageRect.x + pageRect.width;
|
|
290
|
+
const outerBottom = pageRect.y + pageRect.height;
|
|
291
|
+
/** Interior cut, in whole device pixels, with the overlap already applied. */
|
|
292
|
+
const cut = (index, count, extent, grow) => Math.min(extent, Math.max(0, Math.floor((index * extent) / count) + grow));
|
|
293
|
+
for (let row = 0; row < rows; row++) {
|
|
294
|
+
const py0 = row === 0 ? 0 : cut(row, rows, total.height, -PIECE_OVERLAP_PX);
|
|
295
|
+
const py1 = row === rows - 1
|
|
296
|
+
? total.height
|
|
297
|
+
: cut(row + 1, rows, total.height, PIECE_OVERLAP_PX);
|
|
298
|
+
if (py1 - py0 < 1)
|
|
299
|
+
continue;
|
|
300
|
+
for (let col = 0; col < cols; col++) {
|
|
301
|
+
const px0 = col === 0 ? 0 : cut(col, cols, total.width, -PIECE_OVERLAP_PX);
|
|
302
|
+
const px1 = col === cols - 1
|
|
303
|
+
? total.width
|
|
304
|
+
: cut(col + 1, cols, total.width, PIECE_OVERLAP_PX);
|
|
305
|
+
if (px1 - px0 < 1)
|
|
306
|
+
continue;
|
|
307
|
+
const left = col === 0 ? pageRect.x : pageRect.x + px0 / scale;
|
|
308
|
+
const right = col === cols - 1 ? outerRight : pageRect.x + px1 / scale;
|
|
309
|
+
const top = row === 0 ? pageRect.y : pageRect.y + py0 / scale;
|
|
310
|
+
const bottom = row === rows - 1 ? outerBottom : pageRect.y + py1 / scale;
|
|
311
|
+
const docRect = {
|
|
312
|
+
x: left,
|
|
313
|
+
y: top,
|
|
314
|
+
width: right - left,
|
|
315
|
+
height: bottom - top,
|
|
316
|
+
};
|
|
317
|
+
push({
|
|
318
|
+
key: itemKey(page, 'base', docRect, scale),
|
|
319
|
+
page,
|
|
320
|
+
role: 'base',
|
|
321
|
+
docRect,
|
|
322
|
+
scale,
|
|
323
|
+
pixels: { width: px1 - px0, height: py1 - py0 },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return scale;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* One scale for the whole detail set, reduced until the largest single target
|
|
331
|
+
* fits `maxRasterPixels`.
|
|
332
|
+
*
|
|
333
|
+
* Reducing SCALE rather than cropping the rect is the deliberate choice: a
|
|
334
|
+
* crop leaves part of the screen with nothing but the base layer under it,
|
|
335
|
+
* which reads as a hard seam of blur, whereas a uniformly softer detail layer
|
|
336
|
+
* reads as "not sharpened yet". Clamped BEFORE splitting, so the ceiling
|
|
337
|
+
* bounds the epoch's total cost and not merely one piece of it.
|
|
338
|
+
*/
|
|
339
|
+
function resolveDetailScale(targets, requested, policy) {
|
|
340
|
+
let scale = quantizeScale(requested);
|
|
341
|
+
if (!(scale > 0))
|
|
342
|
+
return 0;
|
|
343
|
+
for (const target of targets) {
|
|
344
|
+
// Re-measured against the running scale, so several large pages compound
|
|
345
|
+
// correctly instead of each computing a factor from the original.
|
|
346
|
+
const pixels = scaleToPixels(target.rect, scale);
|
|
347
|
+
const area = pixels.width * pixels.height;
|
|
348
|
+
if (area > policy.maxRasterPixels) {
|
|
349
|
+
scale = quantizeScale(scale * Math.sqrt(policy.maxRasterPixels / area));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return scale;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Split a target into the coarsest grid that keeps every piece inside the piece
|
|
356
|
+
* cap — 1x1, 2x1, 1x2, 2x2, NxM. Pieces have no identity and are never reused
|
|
357
|
+
* across epochs; they exist because a single texture cannot be arbitrarily large
|
|
358
|
+
* (`maxTextureDimension`) and because one oversized raster in flight dominates
|
|
359
|
+
* the whole-flow memory peak (`maxPieceDimension`).
|
|
360
|
+
*/
|
|
361
|
+
function planDetailPieces(page, rect, scale, policy, push) {
|
|
362
|
+
const pixels = scaleToPixels(rect, scale);
|
|
363
|
+
const cols = axisPieceCount(pixels.width, policy);
|
|
364
|
+
const rows = axisPieceCount(pixels.height, policy);
|
|
365
|
+
const overlap = PIECE_OVERLAP_PX / scale;
|
|
366
|
+
const colWidth = rect.width / cols;
|
|
367
|
+
const rowHeight = rect.height / rows;
|
|
368
|
+
const outerRight = rect.x + rect.width;
|
|
369
|
+
const outerBottom = rect.y + rect.height;
|
|
370
|
+
for (let row = 0; row < rows; row++) {
|
|
371
|
+
for (let col = 0; col < cols; col++) {
|
|
372
|
+
// Outer edges are pinned to the target's own edges rather than computed
|
|
373
|
+
// from col*width, so accumulated float error cannot leave a sliver of
|
|
374
|
+
// the requested rect uncovered. Interior edges carry the overlap.
|
|
375
|
+
const left = col === 0 ? rect.x : rect.x + col * colWidth - overlap;
|
|
376
|
+
const right = col === cols - 1 ? outerRight : rect.x + (col + 1) * colWidth + overlap;
|
|
377
|
+
const top = row === 0 ? rect.y : rect.y + row * rowHeight - overlap;
|
|
378
|
+
const bottom = row === rows - 1
|
|
379
|
+
? outerBottom
|
|
380
|
+
: rect.y + (row + 1) * rowHeight + overlap;
|
|
381
|
+
const snapped = snapRectToDevicePixels({ x: left, y: top, width: right - left, height: bottom - top }, scale);
|
|
382
|
+
if (snapped.pixels.width < 1 || snapped.pixels.height < 1)
|
|
383
|
+
continue;
|
|
384
|
+
push({
|
|
385
|
+
key: itemKey(page, 'detail', snapped.docRect, scale),
|
|
386
|
+
page,
|
|
387
|
+
role: 'detail',
|
|
388
|
+
docRect: snapped.docRect,
|
|
389
|
+
scale,
|
|
390
|
+
pixels: snapped.pixels,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/* ------------------------------------------------------------------ *
|
|
396
|
+
* When
|
|
397
|
+
* ------------------------------------------------------------------ */
|
|
398
|
+
/**
|
|
399
|
+
* Evaluate the re-raster predicates. Called at settle (and by the quiet timer
|
|
400
|
+
* on the continuous path), never per frame.
|
|
401
|
+
*
|
|
402
|
+
* When `reraster` is false, `reason` names the predicate that WOULD have fired
|
|
403
|
+
* had `minEpochIntervalMs` not blocked it — so the caller can log a meaningful
|
|
404
|
+
* skip — and is `'stale'` as a neutral filler when nothing fired at all.
|
|
405
|
+
* Callers must branch on `reraster`, never on `reason` alone.
|
|
406
|
+
*
|
|
407
|
+
* `'layout'` is never returned: a layout change is not visible from a
|
|
408
|
+
* viewport snapshot, and the owner of the layout must force the epoch itself.
|
|
409
|
+
*/
|
|
410
|
+
export function shouldReraster(args) {
|
|
411
|
+
const { next, previous, epoch, policy, nowMs } = args;
|
|
412
|
+
// Nothing on screen yet. The floor does not apply: it exists to stop a
|
|
413
|
+
// raster storm between epochs, and there is no previous epoch to space from.
|
|
414
|
+
if (epoch === null)
|
|
415
|
+
return { reraster: true, reason: 'initial' };
|
|
416
|
+
const candidate = evaluate(next, previous, epoch, policy, nowMs);
|
|
417
|
+
if (candidate === null)
|
|
418
|
+
return { reraster: false, reason: 'stale' };
|
|
419
|
+
if (nowMs - epoch.committedAtMs < policy.minEpochIntervalMs) {
|
|
420
|
+
return { reraster: false, reason: candidate };
|
|
421
|
+
}
|
|
422
|
+
return { reraster: true, reason: candidate };
|
|
423
|
+
}
|
|
424
|
+
function evaluate(next, previous, epoch, policy, nowMs) {
|
|
425
|
+
const covered = boundsOf(epoch.coveredRects);
|
|
426
|
+
// COVERAGE. Nothing covered at all means nothing is on screen: fire, whatever
|
|
427
|
+
// else is true.
|
|
428
|
+
if (covered === null)
|
|
429
|
+
return 'coverage';
|
|
430
|
+
const tolerance = next.scale > 0 ? 0.5 / next.scale : 0;
|
|
431
|
+
if (coverageFallsShort(next.visibleDocRect, epoch, tolerance)) {
|
|
432
|
+
return 'coverage';
|
|
433
|
+
}
|
|
434
|
+
// SHARPNESS, measured against the LAST RASTER's scale — not the scale the
|
|
435
|
+
// gesture began at. Comparing against gesture start makes a zoom out and
|
|
436
|
+
// back fire when nothing actually changed.
|
|
437
|
+
if (epoch.detailScale > 0) {
|
|
438
|
+
const ratio = next.scale / epoch.detailScale;
|
|
439
|
+
if (ratio > policy.sharpnessBand || ratio < 1 / policy.sharpnessBand) {
|
|
440
|
+
return 'sharpness';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// DIRECTIONAL LOOK-AHEAD. Needs two snapshots to know which way the content
|
|
444
|
+
// is travelling; with only one, skip the predicate rather than guess a
|
|
445
|
+
// direction and pre-raster the wrong side.
|
|
446
|
+
if (previous !== null && looksAhead(next, previous, covered, policy)) {
|
|
447
|
+
return 'look-ahead';
|
|
448
|
+
}
|
|
449
|
+
// STALE. Forces one raster under input that never goes quiet. Gated on the
|
|
450
|
+
// viewport actually having moved: without that it fires forever on a
|
|
451
|
+
// stationary view, since elapsed only grows.
|
|
452
|
+
const moved = previous !== null && !viewportsEqual(next, previous);
|
|
453
|
+
if (moved && nowMs - epoch.committedAtMs >= policy.maxStaleMs)
|
|
454
|
+
return 'stale';
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Is anything this epoch was responsible for showing not covered by it?
|
|
459
|
+
*
|
|
460
|
+
* TWO QUESTIONS, and both are load-bearing.
|
|
461
|
+
*
|
|
462
|
+
* 1. AM I LOOKING SOMEWHERE THE EPOCH NEVER PLANNED? Containment of the visible
|
|
463
|
+
* rect in the bounds of the epoch's page rects. Asymmetric on purpose, as the
|
|
464
|
+
* old covered-rect containment test was: zooming IN shrinks the visible rect
|
|
465
|
+
* inside them and does not fire, while pinching OUT or scrolling onto the next
|
|
466
|
+
* page escapes them and always does.
|
|
467
|
+
*
|
|
468
|
+
* It can still fire persistently when the viewport is LARGER than the pages on
|
|
469
|
+
* screen (letterboxed at fit zoom, or the margin beside a narrow page in a
|
|
470
|
+
* centred layout). That is unchanged and intended: the re-plan is identical
|
|
471
|
+
* and the identical-plan check downstream absorbs it without a raster.
|
|
472
|
+
*
|
|
473
|
+
* 2. IS ANY PAGE AREA ON SCREEN UNPAINTED? An exact union test of the covered SET
|
|
474
|
+
* over each page's visible part. This is what a bounding rect cannot answer: a
|
|
475
|
+
* tiled epoch that lost a piece — a backend error, a budget refusal, 8-32
|
|
476
|
+
* chances per epoch — keeps its bounds intact, since removing any one cell of
|
|
477
|
+
* a 2x2 grid leaves the other three spanning it. A bounds test therefore
|
|
478
|
+
* reports full coverage over a hole and the hole is never re-planned; this
|
|
479
|
+
* fires on the next evaluation, and the re-plan asks for the same key because
|
|
480
|
+
* keys are derived from geometry.
|
|
481
|
+
*
|
|
482
|
+
* PER PAGE, NOT OVER THE WHOLE VISIBLE RECT, and that is not a detail: most of
|
|
483
|
+
* the doc plane is not page. See `EpochState.pageRects`.
|
|
484
|
+
*/
|
|
485
|
+
function coverageFallsShort(visible, epoch, tolerance) {
|
|
486
|
+
const responsible = boundsOf(epoch.pageRects);
|
|
487
|
+
if (responsible === null)
|
|
488
|
+
return true;
|
|
489
|
+
if (!rectContains(responsible, visible, tolerance))
|
|
490
|
+
return true;
|
|
491
|
+
for (const pageRect of epoch.pageRects) {
|
|
492
|
+
const onScreen = rectIntersection(pageRect, visible);
|
|
493
|
+
if (onScreen === null || isEmptyRect(onScreen))
|
|
494
|
+
continue;
|
|
495
|
+
if (!rectsCover(epoch.coveredRects, onScreen, tolerance))
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
function looksAhead(next, previous, covered, policy) {
|
|
501
|
+
const now = next.visibleDocRect;
|
|
502
|
+
const before = previous.visibleDocRect;
|
|
503
|
+
const dx = now.x + now.width / 2 - (before.x + before.width / 2);
|
|
504
|
+
const dy = now.y + now.height / 2 - (before.y + before.height / 2);
|
|
505
|
+
const epsilon = next.scale > 0 ? MOTION_EPSILON_PX / next.scale : MOTION_EPSILON_PX;
|
|
506
|
+
return (axisLooksAhead(dx, now.x, now.x + now.width, covered.x, covered.x + covered.width, epsilon, policy.movementThreshold) ||
|
|
507
|
+
axisLooksAhead(dy, now.y, now.y + now.height, covered.y, covered.y + covered.height, epsilon, policy.movementThreshold));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Fire when the padding left AHEAD of the motion drops below `threshold` times
|
|
511
|
+
* the padding already consumed BEHIND it. Equivalently: total padding over
|
|
512
|
+
* remaining padding exceeds (1 + t) / t — 3x at pdf.js's 0.5.
|
|
513
|
+
*/
|
|
514
|
+
function axisLooksAhead(delta, visibleMin, visibleMax, coveredMin, coveredMax, epsilon, threshold) {
|
|
515
|
+
if (Math.abs(delta) <= epsilon)
|
|
516
|
+
return false;
|
|
517
|
+
const forward = delta > 0;
|
|
518
|
+
const ahead = forward ? coveredMax - visibleMax : visibleMin - coveredMin;
|
|
519
|
+
const behind = forward ? visibleMin - coveredMin : coveredMax - visibleMax;
|
|
520
|
+
// No padding consumed behind means the view has not travelled far enough for
|
|
521
|
+
// the ratio to mean anything (and if it is negative, the view is already
|
|
522
|
+
// outside the covered rect, which is coverage's call, not ours).
|
|
523
|
+
if (!(behind > 0))
|
|
524
|
+
return false;
|
|
525
|
+
return ahead < threshold * behind;
|
|
526
|
+
}
|
|
527
|
+
function viewportsEqual(a, b) {
|
|
528
|
+
return (Math.abs(a.scale - b.scale) <= 1e-9 &&
|
|
529
|
+
rectsEqual(a.visibleDocRect, b.visibleDocRect));
|
|
530
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cadence constants, in one place, each with the reasoning that produced
|
|
3
|
+
* it. None of these are measured on this codebase yet — where a value is a
|
|
4
|
+
* placeholder for a measurement, the comment says so out loud.
|
|
5
|
+
*/
|
|
6
|
+
import type { RasterPolicy } from './types.js';
|
|
7
|
+
export declare const DEFAULT_POLICY: RasterPolicy;
|
|
8
|
+
/**
|
|
9
|
+
* Merge a partial override over the defaults. An explicitly-`undefined` field
|
|
10
|
+
* is IGNORED rather than allowed to erase a default — `{quietPanMs: maybe}`
|
|
11
|
+
* with an unset `maybe` is the common way a host would otherwise nuke a
|
|
12
|
+
* constant and get a zero-delay raster storm.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolvePolicy(partial?: Partial<RasterPolicy>): RasterPolicy;
|