@reekon-tools/react-native-pdf-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/PdfCanvas.podspec +80 -0
  2. package/README.md +1391 -0
  3. package/android/build.gradle +175 -0
  4. package/android/consumer-rules.pro +3 -0
  5. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  6. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  7. package/android/gradle.properties +2 -0
  8. package/android/gradlew +248 -0
  9. package/android/gradlew.bat +98 -0
  10. package/android/settings.gradle +34 -0
  11. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +226 -0
  12. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +239 -0
  13. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +251 -0
  14. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +221 -0
  15. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +783 -0
  16. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +1388 -0
  17. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +98 -0
  18. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +324 -0
  19. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +775 -0
  20. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +20 -0
  21. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +558 -0
  22. package/android/src/jsi/cpp/CMakeLists.txt +33 -0
  23. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +469 -0
  24. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +140 -0
  25. package/android/src/main/AndroidManifest.xml +7 -0
  26. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +45 -0
  27. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +564 -0
  28. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +33 -0
  29. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +277 -0
  30. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +214 -0
  31. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +30 -0
  32. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +234 -0
  33. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +47 -0
  34. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +143 -0
  35. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +95 -0
  36. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +626 -0
  37. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasPackage.java +33 -0
  38. package/android/tools/compile-gate.sh +289 -0
  39. package/dist/cache.d.ts +161 -0
  40. package/dist/cache.js +389 -0
  41. package/dist/clock.d.ts +21 -0
  42. package/dist/clock.js +31 -0
  43. package/dist/controller.d.ts +131 -0
  44. package/dist/controller.js +1002 -0
  45. package/dist/geometry.d.ts +96 -0
  46. package/dist/geometry.js +248 -0
  47. package/dist/index.d.ts +52 -0
  48. package/dist/index.js +50 -0
  49. package/dist/index.native.d.ts +21 -0
  50. package/dist/index.native.js +23 -0
  51. package/dist/index.web.d.ts +24 -0
  52. package/dist/index.web.js +26 -0
  53. package/dist/layout.d.ts +78 -0
  54. package/dist/layout.js +152 -0
  55. package/dist/peer-check.d.ts +83 -0
  56. package/dist/peer-check.js +183 -0
  57. package/dist/planner.d.ts +35 -0
  58. package/dist/planner.js +530 -0
  59. package/dist/policy.d.ts +14 -0
  60. package/dist/policy.js +168 -0
  61. package/dist/rasterizer/android.d.ts +121 -0
  62. package/dist/rasterizer/android.js +126 -0
  63. package/dist/rasterizer/buffer.d.ts +18 -0
  64. package/dist/rasterizer/buffer.js +18 -0
  65. package/dist/rasterizer/fake.d.ts +92 -0
  66. package/dist/rasterizer/fake.js +433 -0
  67. package/dist/rasterizer/index.d.ts +38 -0
  68. package/dist/rasterizer/index.js +69 -0
  69. package/dist/rasterizer/index.native.d.ts +19 -0
  70. package/dist/rasterizer/index.native.js +80 -0
  71. package/dist/rasterizer/index.web.d.ts +63 -0
  72. package/dist/rasterizer/index.web.js +75 -0
  73. package/dist/rasterizer/ingest.d.ts +180 -0
  74. package/dist/rasterizer/ingest.js +307 -0
  75. package/dist/rasterizer/ios.d.ts +84 -0
  76. package/dist/rasterizer/ios.js +155 -0
  77. package/dist/rasterizer/native-bridge.d.ts +261 -0
  78. package/dist/rasterizer/native-bridge.js +413 -0
  79. package/dist/rasterizer/web/client.d.ts +84 -0
  80. package/dist/rasterizer/web/client.js +359 -0
  81. package/dist/rasterizer/web/engine.d.ts +69 -0
  82. package/dist/rasterizer/web/engine.js +308 -0
  83. package/dist/rasterizer/web/index.d.ts +26 -0
  84. package/dist/rasterizer/web/index.js +21 -0
  85. package/dist/rasterizer/web/pdfium.d.ts +213 -0
  86. package/dist/rasterizer/web/pdfium.js +170 -0
  87. package/dist/rasterizer/web/protocol.d.ts +138 -0
  88. package/dist/rasterizer/web/protocol.js +37 -0
  89. package/dist/rasterizer/web/session.d.ts +106 -0
  90. package/dist/rasterizer/web/session.js +413 -0
  91. package/dist/rasterizer/web/worker.d.ts +44 -0
  92. package/dist/rasterizer/web/worker.js +76 -0
  93. package/dist/react/PdfContentView.d.ts +98 -0
  94. package/dist/react/PdfContentView.js +189 -0
  95. package/dist/react/index.d.ts +6 -0
  96. package/dist/react/index.js +3 -0
  97. package/dist/react/usePdfDocument.d.ts +134 -0
  98. package/dist/react/usePdfDocument.js +275 -0
  99. package/dist/react/usePdfLayer.d.ts +94 -0
  100. package/dist/react/usePdfLayer.js +388 -0
  101. package/dist/skia/index.d.ts +26 -0
  102. package/dist/skia/index.js +25 -0
  103. package/dist/skia/index.native.d.ts +14 -0
  104. package/dist/skia/index.native.js +16 -0
  105. package/dist/skia/index.web.d.ts +12 -0
  106. package/dist/skia/index.web.js +14 -0
  107. package/dist/skia/resolve.native.d.ts +18 -0
  108. package/dist/skia/resolve.native.js +22 -0
  109. package/dist/skia/resolve.web.d.ts +41 -0
  110. package/dist/skia/resolve.web.js +62 -0
  111. package/dist/skia/skia-api.d.ts +94 -0
  112. package/dist/skia/skia-api.js +113 -0
  113. package/dist/testing/index.d.ts +27 -0
  114. package/dist/testing/index.js +28 -0
  115. package/dist/testing/scenes.d.ts +100 -0
  116. package/dist/testing/scenes.js +267 -0
  117. package/dist/types.d.ts +611 -0
  118. package/dist/types.js +21 -0
  119. package/dist/web-init.d.ts +68 -0
  120. package/dist/web-init.js +64 -0
  121. package/ios/Package.swift +58 -0
  122. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +743 -0
  123. package/ios/Sources/PdfCanvasBridge/include/PdfCanvasModule.h +36 -0
  124. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +38 -0
  125. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +389 -0
  126. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +57 -0
  127. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +44 -0
  128. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +82 -0
  129. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +37 -0
  130. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +158 -0
  131. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +80 -0
  132. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +65 -0
  133. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +548 -0
  134. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +48 -0
  135. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +26 -0
  136. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +122 -0
  137. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +80 -0
  138. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +79 -0
  139. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +129 -0
  140. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +100 -0
  141. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +65 -0
  142. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +111 -0
  143. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +85 -0
  144. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +163 -0
  145. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +297 -0
  146. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +140 -0
  147. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +358 -0
  148. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +262 -0
  149. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +173 -0
  150. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +114 -0
  151. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +158 -0
  152. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +544 -0
  153. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +287 -0
  154. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +396 -0
  155. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +639 -0
  156. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +179 -0
  157. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +283 -0
  158. package/package.json +144 -0
  159. package/react-native.config.js +40 -0
  160. package/scripts/check-skia-peer.mjs +86 -0
  161. package/scripts/skia-peer-check.mjs +363 -0
@@ -0,0 +1,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
+ }
@@ -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;