@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/README.md
ADDED
|
@@ -0,0 +1,1391 @@
|
|
|
1
|
+
# @reekon-tools/react-native-pdf-canvas
|
|
2
|
+
|
|
3
|
+
PDF pages as Skia images, drawn at exact doc-space rects inside **somebody else's**
|
|
4
|
+
transform.
|
|
5
|
+
|
|
6
|
+
This package owns no `<Canvas>`, no gestures, no viewport state and no document model.
|
|
7
|
+
It contributes **zero** Reanimated shared values to the Skia subtree, so a host that
|
|
8
|
+
drives its world transform from the UI thread keeps its per-frame budget of one.
|
|
9
|
+
|
|
10
|
+
> **Status: 0.1.0, pre-release.** The pure core, the cadence controller, the React
|
|
11
|
+
> layer and a deterministic fake backend are implemented and tested. **All three
|
|
12
|
+
> backends now exist**: Android (`PdfRenderer`) and iOS (CoreGraphics + PDFKit) are
|
|
13
|
+
> implemented and proven on device end to end; web (PDFium in a worker) is implemented
|
|
14
|
+
> but must be configured, because how a worker is built and where `pdfium.wasm` lives
|
|
15
|
+
> are the host bundler's business. On a platform with no native module
|
|
16
|
+
> `getDefaultRasterizer()` returns one that throws a descriptive `PdfError` at
|
|
17
|
+
> `open()`, and everything else is usable against `createFakeRasterizer()`.
|
|
18
|
+
>
|
|
19
|
+
> The platform backend is **not** installed for you. It lives on its own subpath so that
|
|
20
|
+
> importing the package never drags a native module into a web or test bundle:
|
|
21
|
+
>
|
|
22
|
+
> ```ts
|
|
23
|
+
> import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
|
|
24
|
+
> import {getDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
|
|
25
|
+
>
|
|
26
|
+
> setDefaultRasterizer(getDefaultRasterizer()); // once, at app startup
|
|
27
|
+
> ```
|
|
28
|
+
>
|
|
29
|
+
> Skip it and every `openPdfDocument` / `usePdfDocument` call fails with
|
|
30
|
+
> `unsupported: No PageRasterizer available` — the two `getDefaultRasterizer`s are
|
|
31
|
+
> deliberately different functions (see `src/rasterizer/index.native.ts`), and only the
|
|
32
|
+
> subpath one knows about the native module.
|
|
33
|
+
|
|
34
|
+
## Subpaths, and why the root has none of Skia in it
|
|
35
|
+
|
|
36
|
+
| Subpath | What it is |
|
|
37
|
+
| -------------- | ------------------------------------------------------------------------------- |
|
|
38
|
+
| `.` | Pure core, layouts, policy, controller, cache, planner, hooks, `PdfContentView` |
|
|
39
|
+
| `./skia` | `ingestRaster` / `imageFromPixels` / `rasterByteLength`, and `skiaApi()` |
|
|
40
|
+
| `./rasterizer` | Platform backend registry (`getDefaultRasterizer`) and backend helpers |
|
|
41
|
+
| `./web-init` | `loadPdfCanvasSkiaWeb()` — web only |
|
|
42
|
+
| `./worker` | The PDFium module worker. A bundler entry point, web only |
|
|
43
|
+
| `./testing` | `createFakeRasterizer()` and the deterministic scenes |
|
|
44
|
+
|
|
45
|
+
**Nothing reachable from `.` touches the Skia runtime at module scope, and that is
|
|
46
|
+
enforced by a test, not by convention** (`src/__tests__/module-ordering.test.ts` runs a
|
|
47
|
+
real Vite build of each entry and inspects the module graph and the emitted text).
|
|
48
|
+
|
|
49
|
+
The reason is one line in `@shopify/react-native-skia`:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
// lib/module/skia/Skia.web.js
|
|
53
|
+
export const Skia = JsiSkApi(global.CanvasKit);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That call is taken at **module scope, once, for the life of the bundle**. Reach it before
|
|
57
|
+
`LoadSkiaWeb` has resolved and every factory on `Skia` closes over `undefined` — and
|
|
58
|
+
re-importing the specifier hands back the identical broken object, because ES modules
|
|
59
|
+
evaluate once. There is no in-bundle recovery.
|
|
60
|
+
|
|
61
|
+
It is easy to reach by accident: RN Skia declares no `sideEffects: false` and no
|
|
62
|
+
`exports` map, and its root barrel funnels everything through `export {Skia} from
|
|
63
|
+
"./Skia"`, so **any** value import of the bare specifier — a `<Group>`, a `ColorType` —
|
|
64
|
+
evaluates it. rock-desktop is `{ssr: true}` with `flatRoutes()` and no prerender, so
|
|
65
|
+
every route module is evaluated in Node on every request; one route naming this package's
|
|
66
|
+
root would have been enough.
|
|
67
|
+
|
|
68
|
+
So: `AlphaType` / `ColorType` and the four Skia node components are imported from their
|
|
69
|
+
own leaf modules, `Skia` itself is reached through a call-time `skiaApi()` accessor
|
|
70
|
+
(`src/skia/skia-api.ts`), and the ingest seam lives on `./skia` rather than on the root.
|
|
71
|
+
|
|
72
|
+
**On native none of this applies** — `Skia` there is `global.SkiaApi`, installed
|
|
73
|
+
synchronously by RN Skia's own import-time side effect — which is why
|
|
74
|
+
`src/index.native.ts` imports the barrel quite happily and only the web entries are under
|
|
75
|
+
the claim.
|
|
76
|
+
|
|
77
|
+
### Web startup
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import {loadPdfCanvasSkiaWeb} from '@reekon-tools/react-native-pdf-canvas/web-init';
|
|
81
|
+
|
|
82
|
+
// Once, before the first raster. The locator is YOURS — this package never
|
|
83
|
+
// hardcodes a CanvasKit URL, because the version that must match is the one your
|
|
84
|
+
// tree installed.
|
|
85
|
+
await loadPdfCanvasSkiaWeb({
|
|
86
|
+
locateFile: file =>
|
|
87
|
+
`https://cdn.jsdelivr.net/npm/canvaskit-wasm@${canvasKitVersion}/bin/full/${file}`,
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A host that already loads CanvasKit its own way (a `WithSkiaWeb` around its canvas, RN
|
|
92
|
+
Skia's bare `LoadSkiaWeb`, a `<script>` that sets `globalThis.CanvasKit`) needs no call:
|
|
93
|
+
the web entry point installs a resolver that reads `globalThis.CanvasKit` on first use.
|
|
94
|
+
`loadPdfCanvasSkiaWeb` is the version you can `await`.
|
|
95
|
+
|
|
96
|
+
`PdfContentView` deliberately does **not** wrap itself in a `WithSkiaWeb`. It mounts
|
|
97
|
+
inside somebody else's `<Group transform>` inside somebody else's `<Canvas>`; a lazy
|
|
98
|
+
component boundary there would break the Skia subtree, and boldr-utils already found that
|
|
99
|
+
`WithSkiaWeb` cannot forward refs through it. Put your own around the whole canvas if you
|
|
100
|
+
want one.
|
|
101
|
+
|
|
102
|
+
## The one rule
|
|
103
|
+
|
|
104
|
+
> **The transform decides _when_ to rasterize, never _what_ to draw.**
|
|
105
|
+
|
|
106
|
+
Doc space is PDF points, y-down, origin at the page's top-left — exactly what PDFium's
|
|
107
|
+
`CPDF_Page::GetDisplayMatrix()` emits. Every rect the package emits lives there, with
|
|
108
|
+
plain-number values constant for the life of an epoch. The host's
|
|
109
|
+
`<Group transform={worldTransform}>` does 100% of the pan and zoom.
|
|
110
|
+
|
|
111
|
+
Two consequences, both load-bearing:
|
|
112
|
+
|
|
113
|
+
- **A stale raster is never wrong, only soft.** That is what makes "don't rasterize
|
|
114
|
+
during a gesture" safe. In screen space a stale raster would be _mispositioned_, which
|
|
115
|
+
is why screen-space designs must re-render continuously.
|
|
116
|
+
- **Registration with the layers above is exact by construction.** One transform,
|
|
117
|
+
applied once, to the page and to whatever you draw on top. There is no error term.
|
|
118
|
+
|
|
119
|
+
The package therefore never reads `worldTransform.value` and never accepts a
|
|
120
|
+
`Transforms3d`. The host **pushes** a plain `{visibleDocRect, scale}` snapshot at settle
|
|
121
|
+
rate.
|
|
122
|
+
|
|
123
|
+
## Usage
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import {Canvas, Group} from '@shopify/react-native-skia';
|
|
127
|
+
import {
|
|
128
|
+
usePdfDocument,
|
|
129
|
+
usePdfLayer,
|
|
130
|
+
PdfContentView,
|
|
131
|
+
} from '@reekon-tools/react-native-pdf-canvas';
|
|
132
|
+
|
|
133
|
+
function Sheet({uri, worldTransform, viewport}) {
|
|
134
|
+
// ABOVE the <Canvas>. Not stylistic — see "Where the hooks go".
|
|
135
|
+
const {document} = usePdfDocument({uri});
|
|
136
|
+
const {content, controller} = usePdfLayer({document});
|
|
137
|
+
|
|
138
|
+
// Your gestures, your transform. Tell the package when you settled.
|
|
139
|
+
const pan = Gesture.Pan()
|
|
140
|
+
.onBegin(() => runOnJS(controller.suppress)())
|
|
141
|
+
.onFinalize(() => runOnJS(controller.settle)(viewport()));
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<GestureDetector gesture={pan}>
|
|
145
|
+
<Canvas style={{flex: 1}}>
|
|
146
|
+
<Group transform={worldTransform}>
|
|
147
|
+
<PdfContentView content={content} />
|
|
148
|
+
{/* everything else, in the same doc space */}
|
|
149
|
+
</Group>
|
|
150
|
+
</Canvas>
|
|
151
|
+
</GestureDetector>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Where the hooks go
|
|
157
|
+
|
|
158
|
+
**Above the `<Canvas>`, always.** React context does not cross the Canvas boundary — RN
|
|
159
|
+
Skia mounts its own reconciler for the Skia subtree — and a magnifier loupe is a
|
|
160
|
+
_second_ Canvas, i.e. a second reconciler root with its own copy of every hook. A cache
|
|
161
|
+
instantiated inside the Canvas is created twice, concurrently, doubling tens of MB.
|
|
162
|
+
Hoisting the hook and passing the same `content` to both canvases is what makes a loupe
|
|
163
|
+
free.
|
|
164
|
+
|
|
165
|
+
### Why `settle()` must be called, not sniffed
|
|
166
|
+
|
|
167
|
+
A `withDecay` fling at 2000 px/s animates for **~38 seconds** at Reanimated's default
|
|
168
|
+
deceleration, changing the shared value every frame the whole time. A "no change for N
|
|
169
|
+
ms" gate never fires; a "delta < 1 px/frame" gate does not fire until ~17.5 s in. On web
|
|
170
|
+
it is ~53 s. And there is no pollable escape: `_animation` is set on start and cleared
|
|
171
|
+
only on _interruption_, never on natural completion — so `if (sv._animation)` is
|
|
172
|
+
permanently true after the first animation.
|
|
173
|
+
|
|
174
|
+
A library cannot observe a consumer's animation from outside. Hence the contract:
|
|
175
|
+
|
|
176
|
+
| Call | When | Delay |
|
|
177
|
+
| ----------------------- | -------------------------------------------------- | -------------------- |
|
|
178
|
+
| `controller.suppress()` | gesture begin | — |
|
|
179
|
+
| `controller.settle(v)` | gesture end, or an animation's completion callback | 0 ms |
|
|
180
|
+
| `controller.hint(v)` | continuous input: wheel, trackpad, momentum | trailing quiet timer |
|
|
181
|
+
|
|
182
|
+
## Render model
|
|
183
|
+
|
|
184
|
+
Two standing layers per page, plus a transient.
|
|
185
|
+
|
|
186
|
+
| Layer | What | Changes when |
|
|
187
|
+
| ---------- | ------------------------------------------------------------------------------ | ------------------------------------------------- |
|
|
188
|
+
| **Base** | Whole page at fit-zoom display scale. An **array** of pieces. | Once on mount; again on rotation / resize |
|
|
189
|
+
| **Detail** | Visible rect inflated 25% each side, at display scale. An **array** of pieces. | Replaced wholesale on settle |
|
|
190
|
+
| _Retiring_ | The previous detail set | Held across the swap, released on the next commit |
|
|
191
|
+
|
|
192
|
+
The base is the entire anti-blank mechanism and is never **emptied** once populated —
|
|
193
|
+
`PdfPageContent.base` is a set of pieces whose union is exactly the page rect, and a page
|
|
194
|
+
inside the live window always has all of it. Every shipping PDF viewer has one — pdf.js's
|
|
195
|
+
base canvas under `PDFPageDetailView`, Apple's `backgroundImageView` under
|
|
196
|
+
`TiledPDFView`, androidx.pdf's `fullPageBitmap` under its `TileBoard`.
|
|
197
|
+
|
|
198
|
+
**Why the detail is viewport-sized rather than whole-page:** `bytes = W·H·scale²·4`. An
|
|
199
|
+
ARCH-D sheet at 1:1 on a DPR-3 phone is 154 MB; at 4× it is 2.46 GB. A viewport-sized
|
|
200
|
+
detail is ~25 MiB **at every zoom**, because it tracks the screen and not the page.
|
|
201
|
+
|
|
202
|
+
**Why both layers are arrays, and TWO caps decide it.**
|
|
203
|
+
|
|
204
|
+
- `maxTextureDimension` (4096) is a **hardware** ceiling. Past it the texture upload
|
|
205
|
+
fails and the piece draws _nothing_ — not a soft failure. 4096 is the only
|
|
206
|
+
near-universal value. On this axis alone a phone never splits; a 1440×900 CSS browser
|
|
207
|
+
at DPR 2 needs 4320 px on an axis and splits into 2.
|
|
208
|
+
- `maxPieceDimension` (1024) is a **memory** policy, and it is the bigger lever of the
|
|
209
|
+
two. One piece's bytes exist simultaneously as a platform bitmap, a transport copy, a
|
|
210
|
+
JS `Uint8Array` and an `SkData`, so the whole-flow transient peak scales with the
|
|
211
|
+
largest _single_ raster in flight, not with the epoch's total. The un-tiled 1755×3798
|
|
212
|
+
phone detail raster (25.4 MiB of pixels) measured a **~237 MiB** transient peak; at
|
|
213
|
+
1024 that is ~29.7 MiB, at 512 ~7.4 MiB, for the same resident bytes. The base gets
|
|
214
|
+
the same treatment, and it was the larger hole: `baseMaxPixels` allows 4 MP, ~4.8× a
|
|
215
|
+
whole 1024-tiled detail epoch.
|
|
216
|
+
|
|
217
|
+
The effective cap is `min(maxPieceDimension, maxTextureDimension)`; the grid is also
|
|
218
|
+
clamped to 32 pieces per axis so a nonsense cap degrades instead of planning millions of
|
|
219
|
+
items. Pieces have no persistent identity and no cross-epoch reuse — this is a coarse
|
|
220
|
+
split derived from a limit and a budget, not a tile pyramid.
|
|
221
|
+
|
|
222
|
+
**Pieces overlap, never inset.** RN Skia's `<Image>` has no src-rect prop; both draw
|
|
223
|
+
paths hardcode src to the whole image. `PdfRaster.docRect` is exactly the rect the bytes
|
|
224
|
+
were rendered for.
|
|
225
|
+
|
|
226
|
+
### How many pieces render at once
|
|
227
|
+
|
|
228
|
+
`RasterizerCapabilities.maxConcurrentRenders` — the **backend's** number, defaulting to
|
|
229
|
+
|
|
230
|
+
1. The controller runs that many lanes over the plan's items, so a backend that never
|
|
231
|
+
declared it keeps the strictly-sequential schedule it was written against, and one that
|
|
232
|
+
did gets `min(bound, plan.items.length)` lanes.
|
|
233
|
+
|
|
234
|
+
Results are written into a **slot indexed by plan position**, never appended as they
|
|
235
|
+
land: pieces overlap, so array order is draw order, and the base-swap test in `commit`
|
|
236
|
+
compares fresh against mounted element by element. Completion order is not observable
|
|
237
|
+
anywhere. One failing piece records one missing key and its siblings carry on; an
|
|
238
|
+
**abort** is the only thing that stops a lane claiming more work, and a render that
|
|
239
|
+
resolves after `destroy()` is dropped _before_ `ingest`, so no SkImage is minted that no
|
|
240
|
+
content object exists to free.
|
|
241
|
+
|
|
242
|
+
**It is not a lever at all on Android — it is a brake, and the numbers say why.** On an
|
|
243
|
+
LG V60 (Snapdragon 865, API 33), a 6-piece detail epoch at the field's 131.25 device px/pt
|
|
244
|
+
with 1024 px tiles, on the JSI transport and over a real field-measure drawing: the whole
|
|
245
|
+
Java side is 116 ms at one lane and 153 ms at two, and the worst of 21 rounds is 137 ms at
|
|
246
|
+
one lane against 238 ms at two. Android therefore declares **1**, and the note on
|
|
247
|
+
`poolSize` in `src/rasterizer/android.ts` carries the four passes and the mechanism. On the
|
|
248
|
+
base64 transport this used to read ~200 ms at one lane and ~150 ms at two, and that win was
|
|
249
|
+
the base64 **encode** spreading across threads — the JSI path deletes the encode, and with
|
|
250
|
+
it the only phase of a tile that was genuinely parallel. The app measured **595 ms per
|
|
251
|
+
tile** on that old transport — so ~95% of what the user then waited for was a
|
|
252
|
+
5,592,408-character base64 string crossing the bridge and being decoded on the JS thread,
|
|
253
|
+
and the JS thread is one thread.
|
|
254
|
+
|
|
255
|
+
That 595 was never the same tile, and the difference is NOT the ingest — `raster.ms` does
|
|
256
|
+
exclude the mount, but the mount is 4 ms. Two things account for the gap, both measured:
|
|
257
|
+
|
|
258
|
+
- **Payload.** `decodeBase64` is dead linear in payload at ~5.6 M chars/s, so per-tile cost
|
|
259
|
+
tracks the raster. The live session's 595 is a **2,674,748-char** tile (bridge 88.86 +
|
|
260
|
+
decode 478.73 = 567.6 ms), not the 5,592,408-char 1024² tile.
|
|
261
|
+
- **Lane contamination.** Across 58 tiles of a live pan/zoom, `bridgeMs` is 83.4 ms p50 for
|
|
262
|
+
the first piece returned in an epoch and **399.6 ms p50 for every later one** — the later
|
|
263
|
+
ones spend that await watching the other lane's `decodeBase64` hold the single JS thread.
|
|
264
|
+
Any bridge figure averaged over all pieces is mostly the other lane's decode.
|
|
265
|
+
|
|
266
|
+
So the 1024² tile measured **serially** is **1109.63 ms** (table below), and serial is the
|
|
267
|
+
LOWER bound — a second lane only inflates it. Use the split-phase numbers; 595 is kept here
|
|
268
|
+
only because it is what the pool sizing was originally reasoned from, and that reasoning is
|
|
269
|
+
now retired: the JS-thread cost the second lane existed to pipeline is 0.02 ms on the JSI
|
|
270
|
+
transport.
|
|
271
|
+
|
|
272
|
+
AOSP serialises `PdfRenderer` behind a process-wide PDFium lock (`openPage` throughput is
|
|
273
|
+
flat at 0.084 / 0.073 / 0.074 ops/ms across 1 / 2 / 4 threads, while an identical pure-Java
|
|
274
|
+
workload scales 2.8×) — and the lock **convoys**, so contending for it makes each call
|
|
275
|
+
slower rather than merely unparallel: four threads inflate per-tile `openPage` 16.2×,
|
|
276
|
+
`render` 10.9× and `recycle+close` 33.6× on the drawing, and `render` 30.9× on a dense
|
|
277
|
+
electrical schematic. With the encode gone a tile is 35.6% parallelisable work against
|
|
278
|
+
60.2% PDFium-serialised, so there is no headroom for the convoy to eat into.
|
|
279
|
+
|
|
280
|
+
Re-measured on the JSI path with `laneScheduleOnTheJsiTransport` — N lanes over one shared
|
|
281
|
+
cursor of 6 plan items, 21 interleaved rounds each. Mean epoch wall clock relative to one
|
|
282
|
+
lane, higher is better:
|
|
283
|
+
|
|
284
|
+
| document | 2 lanes | 3 lanes | 4 lanes |
|
|
285
|
+
| -------------------------- | ---------------------------- | -------------------------- | -------------------------- |
|
|
286
|
+
| field-measure drawing (x4) | 0.82 / 0.81 / 0.95 / 0.76× | 0.54 / 0.61 / 0.85 / 0.58× | 0.59 / 0.58 / 0.92 / 0.55× |
|
|
287
|
+
| dense electrical schematic | 0.48× | 0.41× | 0.38× |
|
|
288
|
+
| synthetic fixture | 1.06–1.25× — see the warning | ~1.00× | ~1.00× |
|
|
289
|
+
|
|
290
|
+
One lane is never beaten on a real document, and the tail — which is what a user sees as a
|
|
291
|
+
stutter — is far worse with more. **The synthetic fixture inverts this verdict** and must
|
|
292
|
+
not be used for it: with no `customer.pdf` pushed, `PdfCanvasPhaseTimingTest` falls back to
|
|
293
|
+
a vector-rects-and-lines page whose `PdfRenderer.render` is 0.8 ms rather than 6.5 ms, which
|
|
294
|
+
shrinks the serialised fraction. The fixture warns that it is an optimistic bound; this is
|
|
295
|
+
the decision that proves it. **Android declares `poolSize` 1** — which is also one file
|
|
296
|
+
descriptor and one parsed document rather than two.
|
|
297
|
+
|
|
298
|
+
### A hole repairs itself
|
|
299
|
+
|
|
300
|
+
An epoch records coverage from what it **produced**, so a piece lost to a backend failure
|
|
301
|
+
or a budget refusal leaves a genuine hole in `EpochState.coveredRects` and the coverage
|
|
302
|
+
predicate fires the moment it is consulted. It used to never be consulted: every entry
|
|
303
|
+
point is host-driven, and a host whose viewport has stopped moving has no reason to
|
|
304
|
+
settle — so the epoch published `settling: true` and stopped, and on the phone that is a
|
|
305
|
+
low-resolution rectangle in the middle of a sharp page that never sharpens.
|
|
306
|
+
|
|
307
|
+
A run that ends with pieces missing now arms **one re-evaluation** against the latest
|
|
308
|
+
viewport (`{type: 'repair'}` in the diagnostics), through the same seam the min-interval
|
|
309
|
+
back-off already uses. It is not a retry timer: it re-enters `evaluate`, so every
|
|
310
|
+
predicate, the min-interval floor and the identical-plan guard still apply, and the plan
|
|
311
|
+
asks for the same geometry-derived keys — every piece that landed is a cache hit and only
|
|
312
|
+
the hole costs a render. The delay doubles from 250 ms to a 30 s ceiling and resets
|
|
313
|
+
whenever the hole changes, so a permanently-broken piece costs a render a minute rather
|
|
314
|
+
than four a second.
|
|
315
|
+
|
|
316
|
+
This is also why `cacheBudgetBytes` was **not** raised. At high zoom a run pins four sets
|
|
317
|
+
at its peak — the base, the mounted detail grid, the `retiring` grid one epoch older, and
|
|
318
|
+
the grid being built — and that is what overruns the budget in the field. The pressure is
|
|
319
|
+
transient by construction: the commit that publishes the partial epoch is the commit that
|
|
320
|
+
releases the retired grid, so the re-evaluation has a whole grid of headroom and fills the
|
|
321
|
+
hole in. A bigger number would paper over a run that is momentarily over by exactly one
|
|
322
|
+
grid, and would have to grow again with the next screen size.
|
|
323
|
+
|
|
324
|
+
## Policy
|
|
325
|
+
|
|
326
|
+
Every cadence constant is a field on `RasterPolicy` with an exported default, because
|
|
327
|
+
they are **derived, not measured**. A consumer with letter-size text documents on a
|
|
328
|
+
DPR-1 display swaps two fields rather than forking the package.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import {
|
|
332
|
+
DEFAULT_POLICY,
|
|
333
|
+
resolvePolicy,
|
|
334
|
+
} from '@reekon-tools/react-native-pdf-canvas';
|
|
335
|
+
|
|
336
|
+
usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The one you should measure before shipping is **`minEpochIntervalMs`** (default 300, a
|
|
340
|
+
placeholder). PDFKit's `draw(with:to:)` and Android's `PdfRenderer.render` are
|
|
341
|
+
synchronous and uninterruptible once entered, so an abort only prevents the _next_ piece
|
|
342
|
+
starting. Set it to your measured p95 raster time, or a user pinch-nudging while reading
|
|
343
|
+
a dimension can supersede every render before it lands and never see detail at all.
|
|
344
|
+
|
|
345
|
+
## Extension seams
|
|
346
|
+
|
|
347
|
+
Four values you inject. No subclassing, no magic-string config.
|
|
348
|
+
|
|
349
|
+
| Seam | Shape |
|
|
350
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
351
|
+
| `PageRasterizer` | The backend. `createFakeRasterizer()` ships for tests and demos. |
|
|
352
|
+
| `RasterPolicy` | Every cadence constant, plus `planRasters` / `shouldReraster` exported as pure functions. |
|
|
353
|
+
| `PdfPageLayout` | `(pages: DocSize[]) => DocRect[]`. Ships `singlePage`, `continuousVertical`, `spread`. |
|
|
354
|
+
| `children` | Skia's own idiom — colorFilter / imageFilter / shader nodes on `<PdfContentView>`. |
|
|
355
|
+
|
|
356
|
+
**Not extensible, deliberately:** the node tree (a consumer-injected component means a
|
|
357
|
+
consumer-injected hook, which the loupe would duplicate), doc space (two conventions is
|
|
358
|
+
how DPR gets applied twice), `SkImage` ownership (the package creates and disposes every
|
|
359
|
+
image it draws), and gestures.
|
|
360
|
+
|
|
361
|
+
## Gotchas encoded in this package
|
|
362
|
+
|
|
363
|
+
Each of these is a real trap that was verified in RN Skia's source, and each is handled
|
|
364
|
+
internally — listed so nobody "fixes" one back:
|
|
365
|
+
|
|
366
|
+
- **`<Image>` defaults to `fit="contain"`.** Every raster is drawn `fit="fill"` into its
|
|
367
|
+
exact doc rect. Contain letterboxes and silently misregisters against the layers above.
|
|
368
|
+
- **Mipmaps do not work.** `ImageCmd::draw` hardcodes `kStrict_SrcRectConstraint`, which
|
|
369
|
+
Skia's header says disables mipmaps and anisotropic filtering. Setting
|
|
370
|
+
`MipmapMode.Linear` silently does nothing. Minification is handled by _scheduling_.
|
|
371
|
+
- **`sampling` is a string union**, mapped to module-level frozen constants. RN Skia's
|
|
372
|
+
`shallowEq` is reference equality, so an inline options object breaks memoization.
|
|
373
|
+
- **A `<Paint>` child would draw the layer twice** — once at the declared paint and once
|
|
374
|
+
at the inherited one — and `paintDeclarations.clear()` runs on the first drawing, so it
|
|
375
|
+
would apply to the base and none of the detail pieces. Only _filter_ children work; the
|
|
376
|
+
component dev-warns otherwise.
|
|
377
|
+
- **An epoch requires a React commit.** `PdfContent` is a new frozen object per epoch;
|
|
378
|
+
identity change _is_ the swap signal. A stable handle mutated in place would never
|
|
379
|
+
reach the Skia tree, because `prepareUpdate` returns null on reference equality.
|
|
380
|
+
- **Retirement never owns a commit.** It is piggybacked onto the commit that mounts the
|
|
381
|
+
next epoch. A retire timer means a second full `stopMapper` → `visit()` →
|
|
382
|
+
`startMapper` over the _consumer's_ entire scene, frequently landing on the first frame
|
|
383
|
+
of the next gesture.
|
|
384
|
+
- **Ingest is one copy on native, two on web.** `SkImages::RasterFromData` shares; web's
|
|
385
|
+
`CanvasKit.MakeImage` copies into a WASM heap that never shrinks.
|
|
386
|
+
- **A mounted raster costs ~2× its pixels, and `PdfRaster.bytes` says so.**
|
|
387
|
+
`RasterFromData` _shares_ the `SkData`, which means it **retains** it for the image's
|
|
388
|
+
whole life, and the first GPU draw uploads a budgeted texture of the same dimensions.
|
|
389
|
+
`cacheBudgetBytes` is unchanged at 96 MB, so its meaning tightened: it covers half as
|
|
390
|
+
many pixels as it nominally used to. It always did — the cache just did not know, and
|
|
391
|
+
was choosing eviction victims on numbers wrong by 2×.
|
|
392
|
+
|
|
393
|
+
## Backends
|
|
394
|
+
|
|
395
|
+
| Platform | Engine | `id` | Status |
|
|
396
|
+
| -------- | -------------------------------------------------------- | --------------------- | -------------------------- |
|
|
397
|
+
| iOS | `CGContextDrawPDFPage` for tiles, PDFKit for annotations | `ios-coregraphics` | ✅ implemented — see below |
|
|
398
|
+
| Android | `PdfRenderer.Page.render(bitmap, null, matrix, …)` | `android-pdfrenderer` | ⚠️ **partial** — see below |
|
|
399
|
+
| Web | `@embedpdf/pdfium` WASM in a worker | `web-pdfium` | ✅ implemented — configure |
|
|
400
|
+
| Any | `createFakeRasterizer()` | configurable | ✅ implemented |
|
|
401
|
+
|
|
402
|
+
**The plan said "use PDFKit, not `CGPDFDocument`, because the latter does not render
|
|
403
|
+
annotations". Both halves of that turned out to be true, and the conclusion was wrong.**
|
|
404
|
+
`CGContextDrawPDFPage` really does draw the content stream without compositing annotation
|
|
405
|
+
appearance streams — which is not a limitation, it is exactly what
|
|
406
|
+
`RasterRequest.annotations: false` asks for, and it is what the controller passes for
|
|
407
|
+
every tile. So iOS ships both and picks per render, which makes it the only backend that
|
|
408
|
+
honours the flag in both directions. See "iOS" below.
|
|
409
|
+
|
|
410
|
+
### Web — PDFium in a module worker
|
|
411
|
+
|
|
412
|
+
**The engine is PDFium, and cross-engine parity is the whole argument.** Android's
|
|
413
|
+
`PdfRenderer` _is_ PDFium, so a user measuring on a phone and a colleague measuring in a
|
|
414
|
+
browser see the same pixels. pdf.js was measured and rejected: on the same doc, rect and
|
|
415
|
+
scale the two engines disagree on **23,473 / 262,144 pixels (8.95%)**, 2,140 of them at
|
|
416
|
+
full `maxDelta` 255, concentrated in glyphs.
|
|
417
|
+
|
|
418
|
+
#### Wiring it up — the package builds no worker and no URL
|
|
419
|
+
|
|
420
|
+
`configureWebPdfium` takes a **worker factory** and a **wasm locator**. It never
|
|
421
|
+
constructs a `Worker` from a path of its own — not a hardcoded URL and not
|
|
422
|
+
`new URL('./worker.js', import.meta.url)`, which works in some bundlers, some `base`
|
|
423
|
+
settings and no SSR pass, and fails at _runtime in the host's production build_ when it
|
|
424
|
+
fails at all.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import PdfCanvasWorker from '@reekon-tools/react-native-pdf-canvas/worker?worker';
|
|
428
|
+
import wasmUrl from '@embedpdf/pdfium/pdfium.wasm?url';
|
|
429
|
+
import {configureWebPdfium} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
|
|
430
|
+
import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
|
|
431
|
+
|
|
432
|
+
setDefaultRasterizer(
|
|
433
|
+
configureWebPdfium({
|
|
434
|
+
createWorker: () => new PdfCanvasWorker(),
|
|
435
|
+
wasm: {url: wasmUrl},
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Any other way of producing a **module** worker is equally fine
|
|
441
|
+
(`new Worker(url, {type: 'module'})`); the entry is ESM and statically imports
|
|
442
|
+
`@embedpdf/pdfium`. `wasm` also takes `{binary}` for a host with its own asset pipeline —
|
|
443
|
+
at the cost of a 4.6 MB structured clone per `open()`, since transferring would neuter the
|
|
444
|
+
host's copy.
|
|
445
|
+
|
|
446
|
+
`@embedpdf/pdfium` is an **optional peer dependency**. Exactly one file imports it —
|
|
447
|
+
`src/rasterizer/web/worker.ts`, behind the `./worker` subpath — so a consumer who ships
|
|
448
|
+
native only never resolves it. Until `configureWebPdfium` is called,
|
|
449
|
+
`getDefaultRasterizer()` returns the self-describing placeholder that fails at `open()`,
|
|
450
|
+
so the pure core, the Skia ingest path and the fake backend keep working untouched.
|
|
451
|
+
|
|
452
|
+
#### What it does
|
|
453
|
+
|
|
454
|
+
- **`pageGeometry` is synchronous and loads no page.** Every page is measured once at
|
|
455
|
+
open with `FPDF_GetPageSizeByIndexF` + `EPDF_GetPageRotationByIndex`, both of which work
|
|
456
|
+
off the page dictionary. The size is the CropBox **after** `/Rotate` — measured: a
|
|
457
|
+
200x100 MediaBox with `/Rotate 90` reports 100x200 — so this package does no rotation
|
|
458
|
+
arithmetic of its own. (`EPDF_GetPageSizeByIndexNormalized` sounds like the one you want
|
|
459
|
+
and is the opposite: it normalises the rotation away.)
|
|
460
|
+
- **An arbitrary doc rect at an arbitrary scale**, by describing the whole page at that
|
|
461
|
+
scale and sliding it: `FPDF_RenderPageBitmap(bmp, page, -round(x·s), -round(y·s),
|
|
462
|
+
round(pageW·s), round(pageH·s), 0, flags)` into a tile-sized bitmap.
|
|
463
|
+
`FPDF_RenderPageBitmapWithMatrix` was measured byte-identical on every rect tried,
|
|
464
|
+
fractional device offsets included, and has a trap this form does not — **a NULL clip
|
|
465
|
+
renders nothing at all**, silently.
|
|
466
|
+
- **The background is filled before PDFium draws**, always. PDFium composites onto the
|
|
467
|
+
destination and never clears it, and a fresh buffer is transparent _black_, which under
|
|
468
|
+
the app's annotation layers is a black page rather than a blank one.
|
|
469
|
+
- **`format: 'rgba8888'`** via `FPDF_REVERSE_BYTE_ORDER`, and **`alpha: 'straight'`**.
|
|
470
|
+
The alpha is measured, not assumed: an antialiased opaque-white edge over a transparent
|
|
471
|
+
page reads `[255, 255, 255, 50]`, where premultiplied would be `[50, 50, 50, 50]`.
|
|
472
|
+
Android is `'premultiplied'`; that disagreement is why `RasterPixels.alpha` is a
|
|
473
|
+
required field rather than a constant in `ingest.ts`.
|
|
474
|
+
- **`rowBytes` is read from `FPDFBitmap_GetStride`**, never computed. PDFium's own
|
|
475
|
+
allocator was tight at every width tried (1, 3, 7, 13, 1023, 1025) — measured, and still
|
|
476
|
+
not assumed, because a wrong stride shears an image instead of failing.
|
|
477
|
+
- **`annotations` genuinely honours both settings** (`FPDF_ANNOT`), unlike Android, which
|
|
478
|
+
can only ever draw them. `usePdfDocument` silently downgrades the request when the
|
|
479
|
+
capability is false, so this is a claim that had to be true rather than optimistic.
|
|
480
|
+
|
|
481
|
+
#### Tile registration: exact, with one measured caveat
|
|
482
|
+
|
|
483
|
+
A tile must equal the same region of a larger render, because detail pieces **overlap**
|
|
484
|
+
rather than abut and the base layer is tiled by the same cap. Measured here, against real
|
|
485
|
+
PDFium:
|
|
486
|
+
|
|
487
|
+
- **Axis-aligned geometry is bit-exact.** 10 rects x 7 scales (1 / 2 / 2.7 / 3 / 3.3 / 4 /
|
|
488
|
+
5.5), fractional and half-pixel device offsets included: **0 mismatching bytes,
|
|
489
|
+
`maxDelta` 0**, on a page inked edge to edge with fractionally-placed fills.
|
|
490
|
+
- **A shallow DIAGONAL antialiased edge differs by one level.** 24 pixels of 9,600 on one
|
|
491
|
+
tile at scale 4; 558 bytes of 80,000 on a 0.7pt hairline grid. Always `maxDelta 1`, and
|
|
492
|
+
tracing the diagonal through the tile's **interior** rather than clustering at its
|
|
493
|
+
border, so it is not a clipping artefact. Invisible on screen, irrelevant to a seam --
|
|
494
|
+
but it qualifies the "0 / 262,144, maxDelta 0" figure this backend was designed around,
|
|
495
|
+
which was taken on a document that evidently had no such edge at that scale. The test
|
|
496
|
+
asserts `maxDelta <= 1` rather than skipping the case, so the exception stays on record.
|
|
497
|
+
|
|
498
|
+
Two things that property **cannot** see, and are checked separately: it is RELATIVE, so a
|
|
499
|
+
constant added to every render's device origin leaves it green (measured -- `+1` on
|
|
500
|
+
`startX` passes the whole registration suite), and it says nothing about a page whose
|
|
501
|
+
`/Rotate` is mishandled. A known doc-space edge at a known device pixel covers the first;
|
|
502
|
+
the geometry tests cover the second.
|
|
503
|
+
|
|
504
|
+
#### One worker per document, and why
|
|
505
|
+
|
|
506
|
+
`close()` **terminates** the worker. The Emscripten heap grows one way — it never returns
|
|
507
|
+
pages to the OS — so on web the _peak_ footprint of a session is permanent for the rest of
|
|
508
|
+
the tab's life. Measured: 24 consecutive 512² tiles and 24 consecutive 1024² tiles leave
|
|
509
|
+
the heap flat at 17.75 MiB, while **one** whole-page 2448x3168 render takes it to 32.625
|
|
510
|
+
MiB and keeps it there. Growth begins at 2048², which is why `maxPieceDimension: 1024` is
|
|
511
|
+
validated on web rather than merely inherited, and why **a whole page is never rendered in
|
|
512
|
+
one buffer here**. Terminating is the only way to give a document's footprint back; the
|
|
513
|
+
cost is one wasm instantiation per document, measured at 19 ms cold and 10 ms warm.
|
|
514
|
+
|
|
515
|
+
#### `interruptibleRender: false`, honestly
|
|
516
|
+
|
|
517
|
+
`FPDF_RenderPageBitmap` is synchronous and a worker reads one message at a time, so
|
|
518
|
+
nothing can interrupt a render that has started. What an abort does is reject the caller
|
|
519
|
+
immediately and stop a **queued** render from starting — which only works because the
|
|
520
|
+
worker drains its queue one render per macrotask, leaving a window in which a `cancel` can
|
|
521
|
+
land. PDFium's progressive API (`FPDF_RenderPageBitmap_Start` / `FPDF_RenderPage_Continue`
|
|
522
|
+
with an `IFSDK_PAUSE`) is what would make the flag true, and is not used.
|
|
523
|
+
|
|
524
|
+
#### What is proven, and where
|
|
525
|
+
|
|
526
|
+
**PDFium runs in vitest under node** — `@embedpdf/pdfium`'s `import` build takes
|
|
527
|
+
`wasmBinary` straight off disk — so `src/__tests__/web-rasterizer.test.ts` (**45 tests**)
|
|
528
|
+
drives the real WASM: the tile-registration property at six rect/scale combinations
|
|
529
|
+
including a fractional and a half-pixel device offset, absolute placement of a known
|
|
530
|
+
doc-space edge, overlapping tiles agreeing inside their overlap, a padded stride forced
|
|
531
|
+
through `FPDFBitmap_CreateEx`'s caller-owned-buffer form, both backgrounds, straight alpha,
|
|
532
|
+
byte order, `/Rotate` for all four rotations, the CropBox, annotations on and off, and the
|
|
533
|
+
error table. The worker's _logic_ is driven the same way, over an in-process channel that
|
|
534
|
+
uses node's own `structuredClone` — transfer list honoured, so a transferred buffer really
|
|
535
|
+
is neutered and "the client must copy a caller's `{data}`" is a claim a test can fail.
|
|
536
|
+
`composition.test.ts` adds the join the two suites miss: a real PDFium raster ingested
|
|
537
|
+
through the real `ingestRaster` and composited by real CanvasKit.
|
|
538
|
+
|
|
539
|
+
Every assertion above that could be satisfied trivially was **mutation-checked** —
|
|
540
|
+
`-round(x*s)` swapped for `round(-x*s)`, the background fill removed, `rowBytes` computed
|
|
541
|
+
as `width * 4`, the byte-order and annotation flags dropped, the drain loop's yield
|
|
542
|
+
deleted, the `{data}` copy removed. Two of those found real holes in the first draft of
|
|
543
|
+
the suite (a fixture that left the tiles on blank paper, and a registration property that
|
|
544
|
+
is blind to a uniform origin shift); both are fixed above.
|
|
545
|
+
|
|
546
|
+
**Not proven:** nothing in this backend has run in a browser or in rock-desktop. What
|
|
547
|
+
cannot run under node is the `self` binding in `worker.ts` (two lines), the `{url}` wasm
|
|
548
|
+
form (Emscripten's `locateFile` wants a URL a browser can fetch), and real `Worker`
|
|
549
|
+
termination — which is the step that actually reclaims the WASM heap, so the memory model
|
|
550
|
+
above is reasoned rather than observed. Those need a QA pass on a real page. Structured
|
|
551
|
+
cloning IS exercised, by node's own `structuredClone` with the transfer list honoured.
|
|
552
|
+
|
|
553
|
+
**Two upstream RN Skia bugs a web host will hit, both diagnosed here and neither ours.**
|
|
554
|
+
Recorded so the next person does not spend a day on them.
|
|
555
|
+
|
|
556
|
+
1. **`dispose()` poisons the canvas element for reuse, which React StrictMode then reuses.**
|
|
557
|
+
`WebGLRenderer.dispose()` (`lib/module/views/SkiaPictureView.web.js`) ends with
|
|
558
|
+
`canvas.getContext('webgl2')?.getExtension('WEBGL_lose_context')?.loseContext()`.
|
|
559
|
+
StrictMode's dev double-invoke runs the effect as mount -> cleanup -> mount against the
|
|
560
|
+
SAME `<canvas>` node, and `getContext` returns the SAME, now-lost context object. It is
|
|
561
|
+
TRUTHY, so skia's `if (!this.contextHandle) throw` guard passes, and `MakeWebGLContext`
|
|
562
|
+
then reads `.rangeMin` off the `null` that `getShaderPrecisionFormat` returns for a lost
|
|
563
|
+
context: **`can't access property "rangeMin", a is null`**. Present in every release
|
|
564
|
+
**2.6.4 through 2.11.0** — reverting does not escape it. The GPU resources are already
|
|
565
|
+
freed by `releaseResourcesAndAbandonContext()` + `deleteContext()` immediately above, so
|
|
566
|
+
`loseContext()` only additionally breaks reuse; removing it fixes the crash and was
|
|
567
|
+
verified over four consecutive mount/dispose cycles on one element. rock-desktop carries
|
|
568
|
+
that as a yarn `patch:`.
|
|
569
|
+
|
|
570
|
+
2. **A pending redraw can outlive the picture it was scheduled for.** The view defers
|
|
571
|
+
drawing (`redraw()` -> `requestAnimationFrame(flushRedraw)`, which reads
|
|
572
|
+
`pictureRef.current` when the frame fires), while the reconciler independently calls
|
|
573
|
+
`disposeRecording(recording)` -> `recording.lastPicture.dispose()`. Nothing clears
|
|
574
|
+
`pictureRef` or cancels the pending frame, so the rAF can draw a deleted picture:
|
|
575
|
+
**`BindingError: Cannot pass deleted object as a pointer of type sk_sp<Picture>`**. The
|
|
576
|
+
only `cancelAnimationFrame` is in an unmount effect, which does not help mid-session.
|
|
577
|
+
|
|
578
|
+
**Measured severity: dev-only.** With StrictMode it reproduces about 1 run in 3 under
|
|
579
|
+
aggressive wheel-zoom (4 GL context acquisitions per load); with StrictMode removed it
|
|
580
|
+
was 0 in 3 (2 acquisitions). It needs the double-mount's two concurrent recordings. It
|
|
581
|
+
is also non-fatal — the frame is dropped and the next redraw recovers; all pages still
|
|
582
|
+
painted in every run. **Deliberately NOT patched**: it is exactly the class of unsafe
|
|
583
|
+
effect StrictMode exists to surface, and guarding it in vendored code would hide a real
|
|
584
|
+
upstream defect for no production benefit. Anything that re-records often — like this
|
|
585
|
+
package's epoch swaps — makes it more likely without causing it; there are no frames
|
|
586
|
+
from this package in the stack.
|
|
587
|
+
|
|
588
|
+
**Deliberately not solved here, and structurally the HOST's to solve: WebGL context loss.** A
|
|
589
|
+
browser may drop the GPU context at any time — a background tab reclaimed, a driver reset,
|
|
590
|
+
a GPU switch on a laptop — and RN Skia has no handling for it at **any** version in
|
|
591
|
+
2.6.2–2.11.0. 2.7.0 went the wrong way: it REMOVED CanvasKit's clone-and-replace fallback
|
|
592
|
+
in favour of a bare `throw new Error("Could not create surface")`. Nothing in this backend
|
|
593
|
+
changes that. The rasters this package mints are CPU `SkImage`s, so the PDF bytes
|
|
594
|
+
themselves survive; what does not survive is the surface they are drawn onto, and the
|
|
595
|
+
failure presents as a thrown error from the host's `<Canvas>` rather than as anything a
|
|
596
|
+
`PdfError` code can describe. A host that must survive it needs to remount its own canvas
|
|
597
|
+
— there is no in-package recovery, and this note exists so that gap is stated rather than
|
|
598
|
+
discovered.
|
|
599
|
+
|
|
600
|
+
**Why deferring is a decision and not an omission**, verified rather than assumed:
|
|
601
|
+
|
|
602
|
+
- **The package creates no GPU resources at all.** No `Surface.Make*`, no
|
|
603
|
+
`makeImageFromView`, no `GrDirectContext`, no GL call anywhere in `src/` — grep it.
|
|
604
|
+
- **The images it caches are CPU rasters, so context loss cannot invalidate them.**
|
|
605
|
+
CanvasKit's `MakeImage` is `_malloc` + `HEAPU8.set` + `_MakeImage` (read from
|
|
606
|
+
`canvaskit-wasm/bin/canvaskit.js`) — a raster image, not a texture. They re-upload on the
|
|
607
|
+
next draw against whatever surface exists then.
|
|
608
|
+
|
|
609
|
+
So there is nothing for this package to drop, re-create, or re-raster on a restore: the
|
|
610
|
+
cache stays valid across the event. Adding recovery machinery here would be adding a
|
|
611
|
+
listener for something that cannot corrupt any state we own. The real gap is RN Skia's, and
|
|
612
|
+
it lives at the `<Canvas>` the host mounts.
|
|
613
|
+
|
|
614
|
+
### iOS — CoreGraphics for tiles, PDFKit for annotations
|
|
615
|
+
|
|
616
|
+
`ios/Sources/PdfCanvasCore/` is a **React-free Objective-C library**. It imports
|
|
617
|
+
CoreGraphics, PDFKit and Foundation, and nothing from React Native, JSI or Nitro — so it
|
|
618
|
+
is provable on a laptop with no device, no simulator and no CocoaPods:
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
cd ios && swift test # 82 tests, ~1s
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
That works because `ios/Package.swift` builds the same sources for **macOS**, where
|
|
625
|
+
CoreGraphics and PDFKit exist and UIKit does not. A UIKit import anywhere in the core
|
|
626
|
+
breaks that build immediately, which is when it should break. It is the same split
|
|
627
|
+
`android/src/main` draws against `android/src/reactnative`, and it exists for the same
|
|
628
|
+
reason: a bug in the binding must never be able to hide as a bug in rasterizing.
|
|
629
|
+
|
|
630
|
+
`ios/Sources/PdfCanvasBridge/` is the React binding and the JSI slot store. It can only
|
|
631
|
+
be built by the podspec — it needs `<jsi/jsi.h>` and `<React/RCTBridgeModule.h>` — and
|
|
632
|
+
its only proof is a device session.
|
|
633
|
+
|
|
634
|
+
#### Two engines, one backend, selected per render
|
|
635
|
+
|
|
636
|
+
| `annotations` | Engine | Who asks for it |
|
|
637
|
+
| ------------- | ----------------------------------- | ------------------------------------------ |
|
|
638
|
+
| `false` | `CGContextDrawPDFPage` | every tile the controller plans |
|
|
639
|
+
| `true` | `-[PDFPage drawWithBox:toContext:]` | `renderPage`'s default — thumbnails, print |
|
|
640
|
+
|
|
641
|
+
**This makes iOS the only backend that honours `annotations: false`.** Android cannot
|
|
642
|
+
(`PdfRenderer.Page.render` takes a render mode and no annotation switch, at any API
|
|
643
|
+
level) and the web PDFium backend does not pass `FPDF_ANNOT`. So an app that draws its
|
|
644
|
+
own copy of a PDF-embedded annotation on the Skia layer sees it **once on iOS and twice
|
|
645
|
+
on Android** — a real cross-platform difference, and the right way round.
|
|
646
|
+
|
|
647
|
+
The two are interchangeable geometrically, which is what makes choosing per render safe:
|
|
648
|
+
on a page with no annotations they agree **byte-for-byte** — 0 of 1,280,000 bytes
|
|
649
|
+
differing, maxDelta 0, at scale 4 over an 800×400 raster.
|
|
650
|
+
|
|
651
|
+
#### What CoreGraphics and PDFKit actually do — measured, because the docs do not say
|
|
652
|
+
|
|
653
|
+
Six behaviours, each measured before any of it was assumed. Four of them decided a line
|
|
654
|
+
of code and two of them contradicted the plan:
|
|
655
|
+
|
|
656
|
+
- **`CGContextDrawPDFPage` applies neither `/Rotate` nor the CropBox origin.** Both are
|
|
657
|
+
in `PdfCanvasPdfToDocTransform`. Drawing a `/Rotate 90` letter page with the rotation
|
|
658
|
+
in the transform puts the marker at the displayed top-right (correct); without it, at
|
|
659
|
+
the top-left, clipped.
|
|
660
|
+
- **`-[PDFPage drawWithBox:toContext:]` applies BOTH — and
|
|
661
|
+
`-[PDFPage boundsForBox:]` does not.** On the same `/Rotate 90` page, `boundsForBox:`
|
|
662
|
+
returns `(0, 0, 612, 792)` while `drawWithBox:` draws a 792×612 landscape page. **The
|
|
663
|
+
widely-copied "flip by `page.bounds(for:).size.height`, then draw" recipe is therefore
|
|
664
|
+
correct only for unrotated pages** — flip a rotated one by that number and it lands
|
|
665
|
+
rotated, off-centre and clipped, with no error anywhere. The post-rotation height comes
|
|
666
|
+
from `PdfCanvasPageGeometry` instead.
|
|
667
|
+
- **A tile is pixel-identical to that region of a whole-page render** — 0 of 41,984
|
|
668
|
+
pixels differing, maxDelta 0, for a 64×41pt tile at doc (37, 23) at scale 4. Spike S1
|
|
669
|
+
measured the same for Android's `PdfRenderer` and S3 for PDFium on web, so the
|
|
670
|
+
off-page-origin transform the whole detail layer depends on is now exact on **all
|
|
671
|
+
three** engines.
|
|
672
|
+
- **Cost is flat in scale.** A 512² tile of a dense 540-line ARCH-D page costs
|
|
673
|
+
0.49 / 0.39 / 0.43 / 0.39 ms at scale 1 / 2 / 4 / 8. CoreGraphics clips rather than
|
|
674
|
+
rasterizing the page and discarding it — the premise "a viewport-sized detail raster is
|
|
675
|
+
constant in zoom" now holds on every engine this package has.
|
|
676
|
+
- **Alpha is premultiplied, and it is not a choice.** `CGBitmapContextCreate` **refuses**
|
|
677
|
+
`kCGImageAlphaLast` and `kCGImageAlphaFirst` — straight alpha, either byte order —
|
|
678
|
+
returning NULL, while `premultipliedLast | 32Big`, `premultipliedFirst | 32Little` and
|
|
679
|
+
`noneSkipLast` are accepted. So an 8-bit RGBA context is premultiplied or alpha-less,
|
|
680
|
+
and alpha-less would break `background: 'transparent'`. **This closes the one cell the
|
|
681
|
+
spec's alpha table had marked NOT MEASURED.** An antialiased opaque-white edge over a
|
|
682
|
+
transparent page reads `[a, a, a, a]`, not `[255, 255, 255, a]`.
|
|
683
|
+
- **`CGBitmapContextCreate` refuses a `bytesPerRow` that is not a multiple of 4** at 32
|
|
684
|
+
bits per pixel (`width * 4 + 1 / + 2 / + 3 / + 15` all NULL; `+ 0 / + 4 / + 8 / + 12 /
|
|
685
|
+
- 16 / + 64` accepted). This backend uses a tight stride, so it is unaffected — but row
|
|
686
|
+
alignment is expressible only in whole pixels if it ever pays for itself.
|
|
687
|
+
|
|
688
|
+
#### Three things iOS does that Android cannot
|
|
689
|
+
|
|
690
|
+
- **Encrypted documents work.** `CGPDFDocumentUnlockWithPassword` gives a clean
|
|
691
|
+
three-way answer, so `password-required`, `password-incorrect` and "opens and renders"
|
|
692
|
+
are all reachable. `PdfRendererPool.open` on Android **rejects any password** with
|
|
693
|
+
`unsupported`, because `android.graphics.pdf.LoadParams` landed at API 35.
|
|
694
|
+
- **No poison guard is needed.** `PdfPreflight.java` exists because on API 24 a
|
|
695
|
+
`PdfRenderer` constructor that throws permanently poisons PDFium for the whole process.
|
|
696
|
+
CoreGraphics returns NULL and carries on — measured: a `%PDF-` header over garbage
|
|
697
|
+
fails as `corrupt` five times in a row, interleaved with good documents that open and
|
|
698
|
+
render fine, in one process. `PdfCanvasPreflight` exists only to tell "no such file"
|
|
699
|
+
from "no permission" from "empty" from "not a PDF", because
|
|
700
|
+
`CGPDFDocumentCreateWithURL` returning NULL is one signal for all four.
|
|
701
|
+
- **`close()` while a render is in flight has no window.** `PdfRendererPool.close()`
|
|
702
|
+
closes leased renderers too and its own comment calls that "the only honest option".
|
|
703
|
+
Here a lease owns a CoreFoundation reference to its document, so `close` cannot free
|
|
704
|
+
one out from under a worker — non-blocking **and** no use-after-free.
|
|
705
|
+
|
|
706
|
+
#### The transport — zero copies, which is one fewer than Android
|
|
707
|
+
|
|
708
|
+
Same shape as Android and the same reason: neither the legacy bridge nor
|
|
709
|
+
new-architecture codegen has a binary type. Measured by reading RN 0.86.2's own source,
|
|
710
|
+
`TurboModuleConvertUtils::convertObjCObjectToJSIValue` handles `NSString`, `NSNumber`,
|
|
711
|
+
`NSDictionary`, `NSArray` and `kCFNull` and returns `undefined` for everything else — so
|
|
712
|
+
an `NSData` in a resolved promise arrives in JS as `undefined`.
|
|
713
|
+
|
|
714
|
+
The fallback is base64. The default is a JSI `ArrayBuffer` over a slot, negotiated at
|
|
715
|
+
construction by round-tripping eight known bytes, exactly as on Android.
|
|
716
|
+
|
|
717
|
+
**What differs is the copy count.** Android's fast path costs one full-raster `memcpy`
|
|
718
|
+
and cannot do better: a `Bitmap` owns its own pixel memory and there is no way to tell
|
|
719
|
+
`PdfRenderer` where to put the pixels. `CGBitmapContextCreate` **takes the destination
|
|
720
|
+
pointer as an argument**, so `PdfCanvasSlotSink` reserves the buffer and CoreGraphics
|
|
721
|
+
writes the tile straight into the memory that becomes the JS `ArrayBuffer`. Zero copies,
|
|
722
|
+
end to end.
|
|
723
|
+
|
|
724
|
+
That is what the `PdfCanvasPixelSink` protocol is for, and it is also why the core can
|
|
725
|
+
stay React-free: it knows how to render into a destination it is handed, and knows
|
|
726
|
+
nothing about what a slot is.
|
|
727
|
+
|
|
728
|
+
A sink may **refuse** its reservation, in which case the render falls through to the heap
|
|
729
|
+
and the tile still goes out as base64. The refusal happens **before** anything is drawn,
|
|
730
|
+
which is the structural advantage over Android — there, `nativeStoreFromBitmap` can only
|
|
731
|
+
refuse after the raster already exists.
|
|
732
|
+
|
|
733
|
+
#### Getting the runtime, on both architectures
|
|
734
|
+
|
|
735
|
+
`installJsiTransport` is a blocking synchronous method, and both halves of that are
|
|
736
|
+
load-bearing. Measured by reading RN 0.86.2:
|
|
737
|
+
|
|
738
|
+
- `ObjCTurboModule::performMethodInvocation` runs a sync method through
|
|
739
|
+
`nativeMethodCallInvoker_->invokeSync`, and `ModuleNativeMethodCallInvoker::invokeSync`
|
|
740
|
+
is `work()` — inline, on the calling thread, which for a JS-initiated call is the **JS
|
|
741
|
+
thread**. That is the only thread where `bridge.runtime` is a live pointer.
|
|
742
|
+
- **A sync method must return an object.** For a non-void, non-promise return the interop
|
|
743
|
+
layer does `void *rawResult; [inv getReturnValue:&rawResult]; result = (__bridge
|
|
744
|
+
id)rawResult;`. A method declared to return `double` or `BOOL` would have its bits
|
|
745
|
+
reinterpreted as an object pointer. `RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD` defaults
|
|
746
|
+
the return type to `id` for exactly this reason.
|
|
747
|
+
|
|
748
|
+
The runtime itself comes from `self.bridge.runtime` through a forward-declared category,
|
|
749
|
+
which is satisfied by `RCTCxxBridge` on the old architecture and by `RCTBridgeProxy`
|
|
750
|
+
under bridgeless. That is not invented here: `@shopify/react-native-skia`'s
|
|
751
|
+
`SkiaManager.mm` does the same thing, in the same app, on the same RN version.
|
|
752
|
+
|
|
753
|
+
#### `maxConcurrentRenders: 1`, and why the Android evidence does not apply
|
|
754
|
+
|
|
755
|
+
**CoreGraphics parallelises. Android's `PdfRenderer` does not.** On a dense 540-line
|
|
756
|
+
ARCH-D page, 24× 1024px tiles at scale 4: **1.00x / 1.66x / 2.22x** at 1 / 2 / 4 lanes
|
|
757
|
+
(Apple-silicon Mac, `swift test`). Android measures **0.82x / 0.54x / 0.59x** on a real
|
|
758
|
+
drawing, because AOSP serialises `PdfRenderer` behind a process-wide PDFium lock that
|
|
759
|
+
_convoys_.
|
|
760
|
+
|
|
761
|
+
So the reason Android declares one lane is not a reason iOS should. **iOS still
|
|
762
|
+
defaults to one**, because the measurement above is a Mac with many cores and a fixture
|
|
763
|
+
whose tiles cost about a millisecond — and the Android note records exactly this mistake
|
|
764
|
+
being made in the other direction, where a synthetic fixture measured 1.06x–1.25x and a
|
|
765
|
+
real drawing measured 0.82x. Raise it with `createIosRasterizer(native, {poolSize: 2})`
|
|
766
|
+
after measuring on a device with a real drawing.
|
|
767
|
+
|
|
768
|
+
#### What is proven, and where
|
|
769
|
+
|
|
770
|
+
**Proven on a Mac, by `swift test`:** 102 tests over the doc-space contract (quadrant
|
|
771
|
+
placement, tile equivalence, overlap agreement, flat cost in scale), the two engines and
|
|
772
|
+
their agreement, all four rotations, offset and overhanging crop boxes, the white
|
|
773
|
+
pre-fill and the transparent case, premultiplied alpha, stride and format, per-page
|
|
774
|
+
content, the eight error codes, encryption in both engines, preflight's header window,
|
|
775
|
+
the sink contract (reserve/publish/abandon, refusal, idempotent release), cancellation at
|
|
776
|
+
each checkpoint, lane correctness against a serial render, and close-while-rendering.
|
|
777
|
+
|
|
778
|
+
That count includes **the real slot store**, not a test double. `PdfCanvasSlots.mm`
|
|
779
|
+
guards `<jsi/jsi.h>` with `__has_include` — the same split
|
|
780
|
+
`android/src/jsi/cpp/pdfcanvas-jsi.cpp` draws — so the storage compiles without React
|
|
781
|
+
Native and the transport's own failure modes get tested: that slot bytes are
|
|
782
|
+
**byte-identical to the heap path for a real raster**, that a slot is taken exactly once,
|
|
783
|
+
that a reserved buffer is unreachable from the store until it is published (the
|
|
784
|
+
reload-lands-mid-draw case), and that every abandon path frees. Those are leaks and
|
|
785
|
+
use-after-frees rather than wrong pixels, so a device session cannot show them — 48 tiles
|
|
786
|
+
rendering correctly while the store leaked every superseded raster would look identical.
|
|
787
|
+
|
|
788
|
+
**Proven in vitest:** the TypeScript adapter, including that both native backends accept
|
|
789
|
+
the same fake native module and marshal the same request to byte-identical pixels.
|
|
790
|
+
|
|
791
|
+
**Proven on device** — an **iPhone 14 Pro Max, iOS 18.7.8**, inside a React Native
|
|
792
|
+
0.86.2 / Expo SDK 57 app running the **new architecture (bridgeless, Fabric)**:
|
|
793
|
+
|
|
794
|
+
- Autolinking installs the pod (`Installing PdfCanvas (0.1.0)`), and
|
|
795
|
+
`NativeModules.PdfCanvas` resolves with every method — including the two
|
|
796
|
+
**blocking synchronous** ones, through the TurboModule interop layer.
|
|
797
|
+
- `installJSIBindingsWithRuntime:callInvoker:` is called and installs
|
|
798
|
+
`__pdfCanvasTakePixels`, and `negotiateTransport` then **armed the fast path**: the
|
|
799
|
+
eight probe bytes round-tripped exactly. This is byte-fidelity of the transport,
|
|
800
|
+
measured on the device rather than argued from the source.
|
|
801
|
+
- **48 real tiles rendered end to end** through the JSI transport into `SkImage`s, in
|
|
802
|
+
a live `PdfContentView` under the app's own pan/zoom. Every one reported
|
|
803
|
+
`chars=0` (nothing was stringified), `decode=0` and `tight=true`, and every byte
|
|
804
|
+
count was exactly `round(docRect × scale) × 4` for its rect. Zero errors, zero
|
|
805
|
+
fallbacks to base64.
|
|
806
|
+
- **Timings, 48 tiles at scale 13.31 device-px/pt (~0.74 Mpx each):**
|
|
807
|
+
|
|
808
|
+
| Phase | p50 | p95 | max |
|
|
809
|
+
| ----------- | -------- | -------- | -------- |
|
|
810
|
+
| `bridgeMs` | 12.79 ms | 42.03 ms | 50.01 ms |
|
|
811
|
+
| `decodeMs` | 0.00 ms | 0.01 ms | 2.62 ms |
|
|
812
|
+
| `ingestMs` | 0.61 ms | 7.69 ms | 23.67 ms |
|
|
813
|
+
| **`total`** | 13.91 ms | 43.09 ms | 60.32 ms |
|
|
814
|
+
|
|
815
|
+
`bridgeMs` is the whole round trip including the native render. `decodeMs` is the
|
|
816
|
+
term the transport deletes — on Android's base64 path the same term was **1005 ms**
|
|
817
|
+
for one 1024² tile. **This is the `p95` raster time the spec lists as "still open"
|
|
818
|
+
for iOS**, and at 43 ms it is far below the 250 ms the quiet-timer default assumes.
|
|
819
|
+
|
|
820
|
+
**Proven by looking at it, on the host.** `PreviewTests.swift` writes what the
|
|
821
|
+
rasterizer produced to PNG — the ARCH-D base, one detail tile, the doc-space quadrants,
|
|
822
|
+
all four rotations, an offset crop box through both engines, the annotation flag both
|
|
823
|
+
ways, a transparent background, and a page from the middle of a multi-page document.
|
|
824
|
+
It is deliberately **not** a golden-image test: byte-exact references churn on every OS
|
|
825
|
+
update, and every property worth asserting is asserted numerically elsewhere. What it
|
|
826
|
+
adds is that somebody can open the files and see that a page looks like a page.
|
|
827
|
+
|
|
828
|
+
**Still not proven:** the combination of the two — a tile drawn by this backend,
|
|
829
|
+
composed by `PdfContentView`, on a real screen — because there is no screenshot or tap
|
|
830
|
+
automation for a physical device in this toolchain, and driving a dev-client Simulator
|
|
831
|
+
needs synthetic taps that need an accessibility grant. Each half is covered separately
|
|
832
|
+
(the rasterizer by the PNGs above, the composition by `composition.test.ts`, which
|
|
833
|
+
renders the real `PdfContentView` through the real RN Skia reconciler into a CanvasKit
|
|
834
|
+
surface). Also unproven: cancellation and the error-code mapping _across the bridge_ —
|
|
835
|
+
both covered in the core, neither exercised from JS — and a RELEASE bundle, since every
|
|
836
|
+
figure above is the dev bundle over Metro.
|
|
837
|
+
|
|
838
|
+
### Android — what is and is not proven
|
|
839
|
+
|
|
840
|
+
**Proven on device:** the rasterizer core and the raster transport. **57 instrumented
|
|
841
|
+
tests** pass on an LG V60 (API 33) — tiling, the white pre-fill, page geometry, byte order
|
|
842
|
+
and stride, pool concurrency, cancellation, the error codes, and the eleven that hold the
|
|
843
|
+
JSI transport to base64's bytes. The core 33 of them have also been run on **API 24, 31 and
|
|
844
|
+
33**; the transport 11 have so far only been run on 33.
|
|
845
|
+
|
|
846
|
+
**Also proven on device:** the JS↔native bridge, on an **API 33 x86_64 emulator**, inside a
|
|
847
|
+
React Native 0.86 / Expo SDK 57 app running the **new architecture (bridgeless, Fabric)**.
|
|
848
|
+
Autolinking registers `PdfCanvasPackage`, and `NativeModules.PdfCanvas` resolves with all
|
|
849
|
+
six methods (`open`, `render`, `cancel`, `close`, `rotationSupport`, `isBackendPoisoned`)
|
|
850
|
+
even though `global.__turboModuleProxy` is `undefined` — the interop layer does backfill a
|
|
851
|
+
legacy module, so `NativeModules` rather than `TurboModuleRegistry` is the correct lookup.
|
|
852
|
+
A two-page document opened (`pageCount: 2`, geometry `200x200` and `300x150`,
|
|
853
|
+
`rotationSupport: supported`). A 100x100pt tile at doc-space origin (50, 50), scale 2, came
|
|
854
|
+
back `200x200`, `rowBytes: 800`, `format: rgba8888`, 160 000 bytes, in 251 ms; its four
|
|
855
|
+
quadrant samples were exactly `[255,0,0,255]`, `[0,255,0,255]`, `[0,0,255,255]`,
|
|
856
|
+
`[0,0,0,255]`, which pins the doc-space origin, the scale, the row order and the channel
|
|
857
|
+
order at once. `imageFromPixels` produced a 200x200 `SkImage` (`colorType` RGBA_8888,
|
|
858
|
+
`alphaType` Premul) whose `readPixels` round-tripped the same values.
|
|
859
|
+
|
|
860
|
+
**Still not proven:** cancellation, pool concurrency and the error-code mapping _across the
|
|
861
|
+
bridge_ — all proven in the core, none of them exercised from JS; the JSI host function
|
|
862
|
+
itself, which no build the instrumented suite can run even contains (see below) and which is
|
|
863
|
+
therefore covered only by the negotiation probe at runtime and by the device sessions in
|
|
864
|
+
`app/dev-pdf-canvas-probe.tsx`; a RELEASE bundle, since every JS-side figure here is the dev
|
|
865
|
+
bundle over Metro; and all of iOS and web.
|
|
866
|
+
|
|
867
|
+
`android/` is a **React-free Android library**. The rasterizer
|
|
868
|
+
(`android/src/main/java/tools/reekon/pdfcanvas/`) imports nothing from React Native or
|
|
869
|
+
Nitro, so it can be proven on a bare emulator with no app around it:
|
|
870
|
+
|
|
871
|
+
```bash
|
|
872
|
+
export JAVA_HOME="/path/to/Android Studio/jbr" # JDK 17
|
|
873
|
+
cd android && ./gradlew connectedDebugAndroidTest # runs on every attached device
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
The React binding lives in `android/src/reactnative/java/` and joins the source set only
|
|
877
|
+
when a host app autolinks the package — see the `standalone` flag in
|
|
878
|
+
`android/build.gradle`. That is what keeps the tested half testable.
|
|
879
|
+
|
|
880
|
+
Three things the platform does that the docs do not mention, all measured on API 24, 31
|
|
881
|
+
and 33:
|
|
882
|
+
|
|
883
|
+
- **A tile is pixel-identical to the same region of a larger render** —
|
|
884
|
+
`setScale(s,s); postTranslate(-x·s, -y·s)`, 0/262144 mismatching pixels on every level.
|
|
885
|
+
Tiling on the platform renderer works.
|
|
886
|
+
- **`/Rotate` is not applied when rendering on API 24.** Page DIMENSIONS swap, the content
|
|
887
|
+
does not, and it lands clipped. 31 and 33 are correct. There is no way to read a page's
|
|
888
|
+
`/Rotate` below API 35, so this cannot be compensated for — the backend probes the
|
|
889
|
+
device and reports `rotationSupport` instead of pretending.
|
|
890
|
+
- **A `PdfRenderer` constructor that THROWS permanently poisons PDFium on API 24.** One
|
|
891
|
+
failed open plus a garbage collection, and the next successful open segfaults in
|
|
892
|
+
`CPDF_Document`. `PdfPreflight` keeps bad files away from the constructor and a latch
|
|
893
|
+
refuses rather than crashing if one gets through.
|
|
894
|
+
|
|
895
|
+
#### The transport — two of them, negotiated at runtime
|
|
896
|
+
|
|
897
|
+
Pixels used to cross the bridge as **base64**, because neither the legacy bridge nor
|
|
898
|
+
codegen has a binary type. That is now the **fallback**. The default is a JSI
|
|
899
|
+
`ArrayBuffer`:
|
|
900
|
+
|
|
901
|
+
1. The render worker finishes a `byte[]` and copies it into C++-owned memory
|
|
902
|
+
(`PdfCanvasPixelBridge.nativeStore`) under a monotonic **slot** id. This is the only
|
|
903
|
+
copy the transport adds, it is 4 MiB of `GetByteArrayRegion`, and it happens on the
|
|
904
|
+
worker — not the JS thread.
|
|
905
|
+
2. The promise resolves with `pixelsSlot` instead of `base64`.
|
|
906
|
+
3. JS calls `__pdfCanvasTakePixels(slot)`, a host function installed from C++, which wraps
|
|
907
|
+
that same memory in a `jsi::ArrayBuffer` via `jsi::MutableBuffer`. **No copy**, and the
|
|
908
|
+
buffer is exactly the raster, so `isTightBuffer` holds by construction.
|
|
909
|
+
4. The slot map drops its reference on the take, so the ArrayBuffer is the sole owner and
|
|
910
|
+
Hermes's GC frees the vector.
|
|
911
|
+
|
|
912
|
+
Three lifetime details that are the difference between this and a use-after-free:
|
|
913
|
+
|
|
914
|
+
- **The slot counter is PROCESS-WIDE (`static`), because the slot map is.** The map is one
|
|
915
|
+
`std::unordered_map` in `pdfcanvas-jsi.cpp`; a per-instance counter starting at 1 would
|
|
916
|
+
hand the same id to two `PdfCanvasModule`s — RN builds a fresh set of native modules for
|
|
917
|
+
every reload — and the second store would silently replace the first's buffer, so that
|
|
918
|
+
module's take would return ANOTHER tile's pixels. A wrong image, not an error.
|
|
919
|
+
- **`nativeStore` converts its C++ failures into Java throwables.** It is the only
|
|
920
|
+
allocating method on the surface, `std::bad_alloc` for a 4 MiB raster is reachable, and a
|
|
921
|
+
C++ exception unwinding out of a JNI method reaches `std::terminate` — so the documented
|
|
922
|
+
"a store that fails falls that tile back to base64" only exists because the throw is
|
|
923
|
+
translated (`OutOfMemoryError`) rather than allowed to escape.
|
|
924
|
+
- **`invalidate()` clears `jsiEnabled` FIRST, then drains.** `shutdownNow` cannot interrupt
|
|
925
|
+
a worker that is inside `PdfRenderer.render`, so it will reach `putPixelsSlot` after
|
|
926
|
+
`nativeReleaseAll` has run; with the flag already off it encodes base64 into a dead
|
|
927
|
+
promise instead of parking a raster nothing can ever take.
|
|
928
|
+
|
|
929
|
+
Nothing downstream pins the buffer: `Skia.Data.fromBytes` is `MakeWithCopy`, so the cache
|
|
930
|
+
holds SkImages and the redeemed ArrayBuffer is garbage the moment ingest returns.
|
|
931
|
+
|
|
932
|
+
**It is proven before it is used, and it degrades in silence-free ways.** `installJsiTransport`
|
|
933
|
+
parks eight known bytes and JS compares them against its own copy of the constant
|
|
934
|
+
(`TRANSPORT_PROBE_BYTES`); only a byte-for-byte match calls `enableJsiTransport(true)`. A
|
|
935
|
+
build with no `libpdfcanvasjsi.so`, an app binary predating the methods, a runtime that
|
|
936
|
+
refuses a host function or an engine that cannot back an ArrayBuffer with external memory
|
|
937
|
+
all leave the flag off and every render goes back to base64 — and the negotiated result is
|
|
938
|
+
reported as `RasterizerCapabilities.transport`, with the per-tile one as
|
|
939
|
+
`RasterPhaseTimings.transport`, precisely because a silent fallback is how a large speedup
|
|
940
|
+
ships as none.
|
|
941
|
+
|
|
942
|
+
**Why not Nitro.** `react-native-nitro-modules` was the first candidate — BOLDR already has
|
|
943
|
+
it at 0.35.2 and the app is bridgeless — and reading its source is what ruled it out.
|
|
944
|
+
`cpp/core/ArrayBuffer.hpp` in the installed copy declares
|
|
945
|
+
`class ArrayBuffer : public jsi::MutableBuffer`, with `wrap(data, size, deleteFn)` and
|
|
946
|
+
`copy(data, size)`: Nitro's ArrayBuffer IS the primitive this file reaches directly. So
|
|
947
|
+
adopting it would buy the same `jsi::MutableBuffer` at the price of a peer dependency every
|
|
948
|
+
consumer must install, a `find_package(NitroModules)` prefab that only resolves in an
|
|
949
|
+
autolinked build, and — unless the spec is hand-written — the `nitrogen` toolchain (not
|
|
950
|
+
installed here) plus checked-in generated sources.
|
|
951
|
+
|
|
952
|
+
Two of those are fatal rather than merely unwelcome. The package's instrumented suite runs
|
|
953
|
+
from a **standalone** `./gradlew connectedDebugAndroidTest` with no React Native and no
|
|
954
|
+
consumer `node_modules` anywhere on the classpath; a Nitro HybridObject cannot compile in
|
|
955
|
+
that build at all, so the 11 tests that prove the transport's byte fidelity would need a
|
|
956
|
+
whole app built around them to run. And a codegen TurboModule is worse still: the spec
|
|
957
|
+
language has no `ArrayBuffer` return type to express.
|
|
958
|
+
|
|
959
|
+
So the transport is ~40 lines of plain JNI + JSI in `android/src/jsi/`, behind one `#ifdef`
|
|
960
|
+
so the identical storage code compiles with and without `jsi.h`, plus a branch in
|
|
961
|
+
`PdfCanvasModule.render`. No new dependency, optional or otherwise; the core does not know
|
|
962
|
+
it happened.
|
|
963
|
+
|
|
964
|
+
#### What that base64 hop cost — measured, not estimated
|
|
965
|
+
|
|
966
|
+
The Android backend fills `RasterPixels.timings` and the controller re-emits it as a
|
|
967
|
+
`raster-phases` diagnostic, so the per-tile cost is split rather than totalled.
|
|
968
|
+
|
|
969
|
+
Measured on an LG V60 (Snapdragon 865, API 33, dev bundle), five **serial** 1024x1024
|
|
970
|
+
tiles at 131.25 device px/pt — 4 194 304 bytes, 5 592 408 base64 characters — medians:
|
|
971
|
+
|
|
972
|
+
| term | ms | share |
|
|
973
|
+
| ---------------------------------------------------------- | ----------- | --------- |
|
|
974
|
+
| bridge round trip (string marshalling + ALL the Java work) | 100.07 | 9.0% |
|
|
975
|
+
| `decodeBase64` on the JS thread | 1005.46 | 90.6% |
|
|
976
|
+
| `Skia.Data.fromBytes` + `Skia.Image.MakeImage` (`ingest`) | **4.09** | **0.37%** |
|
|
977
|
+
| **per tile** | **1109.63** | |
|
|
978
|
+
|
|
979
|
+
The Java half of that bridge figure is 27.5 ms p50 (`PdfCanvasPhaseTimingTest`), of which
|
|
980
|
+
19.88 ms is the base64 ENCODE — so **pure string marshalling is ~72.6 ms**, and the
|
|
981
|
+
un-removable Java work is ~7.6 ms.
|
|
982
|
+
|
|
983
|
+
Four things follow, and each contradicts something that tends to get assumed:
|
|
984
|
+
|
|
985
|
+
- **The decode is the cost. The copies are not.** `decodeBase64` runs at ~5.6 M chars/s in
|
|
986
|
+
Hermes and is dead linear in payload. Skia's two copies together are 4 ms for the same
|
|
987
|
+
4 MiB. The term no transport change can remove is **0.37%** of the tile.
|
|
988
|
+
- **`raster.ms` does NOT include the ingest.** The `raster` diagnostic is emitted before
|
|
989
|
+
`ingest()` is called (see `controller.ts`), so it is `handle.render` alone — bridge plus
|
|
990
|
+
decode. Any "end to end per tile" figure taken from it has the mount missing entirely,
|
|
991
|
+
and subtracting the Java time from it leaves marshalling + decode, not "everything
|
|
992
|
+
between the byte[] and the SkImage".
|
|
993
|
+
- **`bridgeMs` is only meaningful for the first piece of an epoch.** It is wall time across
|
|
994
|
+
an `await`, and the pool ran two lanes when this was captured, so every later piece spent
|
|
995
|
+
that await watching the other lane's decode hold the JS thread: over 58 tiles of a real
|
|
996
|
+
pan/zoom session the contaminated pieces read 399.6 ms p50 against 83.4 ms for the clean
|
|
997
|
+
ones. At `poolSize` 1 there is no other lane, so the contamination is gone — which is a
|
|
998
|
+
side benefit of that change and not a reason for it.
|
|
999
|
+
- **`tighten()` is already a no-op here.** `tight` is true on every sample, because
|
|
1000
|
+
`decodeBase64` allocates an exact buffer. A transport handing back a tightly-sized
|
|
1001
|
+
`ArrayBuffer` removes no copy that is being paid today — it preserves a property this
|
|
1002
|
+
path already has.
|
|
1003
|
+
|
|
1004
|
+
The probe (`app/dev-pdf-canvas-probe.tsx` in the BOLDR app) shows the split live, logs each
|
|
1005
|
+
tile as `PDFCANVAS-PHASES`, and carries two benches that remove the interleaving: `Tile`
|
|
1006
|
+
renders single tiles serially through the real backend (the table above), and `Bench` times
|
|
1007
|
+
`decodeBase64` and the Skia calls on synthetic buffers of exactly those sizes. The two
|
|
1008
|
+
agree to 0.6% on the decode (999.92 vs 1005.46 ms), which is what makes the synthetic one
|
|
1009
|
+
usable when a device session cannot be driven.
|
|
1010
|
+
|
|
1011
|
+
#### What the transport actually bought — measured on device, both paths
|
|
1012
|
+
|
|
1013
|
+
The probe's `Tile` bench renders **the same tile on both transports, back to back, on one
|
|
1014
|
+
handle**, five serial renders each, medians — because a speedup quoted against a number
|
|
1015
|
+
from a previous session on a previous build is two numbers, not a ratio. LG V60, API 33,
|
|
1016
|
+
dev bundle, 1024x1024 device px at 131.25 devpx/pt (4 194 304 bytes):
|
|
1017
|
+
|
|
1018
|
+
| term | `jsi-arraybuffer` | `base64` |
|
|
1019
|
+
| -------------------------- | ----------------- | ----------- |
|
|
1020
|
+
| bridge round trip | **13.88** | 56.01 |
|
|
1021
|
+
| payload decode / slot take | **0.02** | 997.50 |
|
|
1022
|
+
| `ingest` (Skia) | 4.18 | 4.09 |
|
|
1023
|
+
| **per tile** | **17.75** | **1057.60** |
|
|
1024
|
+
| payload characters | **0** | 5 592 408 |
|
|
1025
|
+
| `tight` | true | true |
|
|
1026
|
+
| FNV-1a of the raster | `ac5c9dc5` | `ac5c9dc5` |
|
|
1027
|
+
|
|
1028
|
+
**59.6x**, and the two rasters are byte-identical. Three consecutive runs with nothing else
|
|
1029
|
+
in flight gave 59.57x / 60.80x / 54.34x. A fourth run taken while the screen was still
|
|
1030
|
+
repairing an epoch read 116x — the contamination inflates the BASE64 half (its bridge went
|
|
1031
|
+
56 -> 1348 ms, because that await now watches the other lane's decode), so the honest figure
|
|
1032
|
+
is the quiet one and the noisy one is quoted here only to say why it is not.
|
|
1033
|
+
|
|
1034
|
+
**Replicated in a later session, on a cold JS reload and a different document** (6-page
|
|
1035
|
+
lease, same 1024x1024 tile at 131.25 devpx/pt): fast bridge 15.68, take 0.02, `ingest`
|
|
1036
|
+
3.71, **19.41 ms per tile**, `chars` 0, `tight` true, and the digest `ac5c9dc5` again —
|
|
1037
|
+
identical to the base64 half of that run. Its base64 half WAS contaminated (bridge
|
|
1038
|
+
1230 ms, so it read 108x) which is why the ratio is not quoted from it, but `decodeMs` is
|
|
1039
|
+
a synchronous loop between two clock reads with no await inside it, so contamination
|
|
1040
|
+
cannot touch it: **868.46 ms**, against 997.50 here. The honest range across sessions is
|
|
1041
|
+
therefore ~48-60x, and the term that sets it is the decode, which is why the same digest
|
|
1042
|
+
appearing on both halves matters more than the ratio's last digit.
|
|
1043
|
+
|
|
1044
|
+
The transport's own new cost is the one thing it ADDS, and it is smaller than the encode it
|
|
1045
|
+
deletes: `nativeStore`'s `GetByteArrayRegion` of 4 MiB is **1.67-1.78 ms** against
|
|
1046
|
+
**19.23-19.96 ms** for `Base64.encodeToString` of the same buffer
|
|
1047
|
+
(`PdfCanvasJsiTransportTest.measuresTheStoreCostAgainstTheBase64EncodeItReplaces`), and it
|
|
1048
|
+
runs on the render worker rather than the JS thread.
|
|
1049
|
+
|
|
1050
|
+
**The profile inverts, as the split phase predicted.** Over a real 134-tile pan/zoom session
|
|
1051
|
+
on the fast transport: bridge 23.48 ms p50, take 0.02 ms, `ingest` 3.52 ms, **27.14 ms per
|
|
1052
|
+
tile p50** (min 14.82, max 168.82), and `payloadChars` was **0 on all 134** — no tile
|
|
1053
|
+
silently fell back. `ingest` is now **13%** of the median tile against 0.37% before, while
|
|
1054
|
+
being the same 3-4 ms it always was. That is the point at which Skia's two copies become
|
|
1055
|
+
worth a second look, and not before.
|
|
1056
|
+
|
|
1057
|
+
**`tighten()` is a no-op on this path, confirmed rather than assumed.** `tight` was true on
|
|
1058
|
+
every sample of both transports — 15 bench renders and all 134 live ones. The redeemed
|
|
1059
|
+
ArrayBuffer is exactly the raster (`byteOffset` 0, `byteLength` = `buffer.byteLength` =
|
|
1060
|
+
4 194 304), so `Skia.Data.fromBytes` — which ignores `byteOffset`/`byteLength` and copies
|
|
1061
|
+
the whole buffer — copies the raster and nothing else. The transport PRESERVES a property
|
|
1062
|
+
the base64 path already had; it does not remove a copy that was being paid.
|
|
1063
|
+
|
|
1064
|
+
## Versions and peer ranges
|
|
1065
|
+
|
|
1066
|
+
**Every `devDependency` that the app also ships is pinned EXACTLY.** Not a caret — a
|
|
1067
|
+
caret is what went wrong. `"@shopify/react-native-skia": "^2.6.2"` had quietly resolved
|
|
1068
|
+
to **2.11.0** here while BOLDR pinned **2.6.2**, so the whole suite, every offscreen
|
|
1069
|
+
CanvasKit pixel test included, had been validating a version the app does not ship. A
|
|
1070
|
+
library validated against a floating version is not validated. The pinned set is
|
|
1071
|
+
`@shopify/react-native-skia 2.11.0`, `react 19.2.3`, `react-native 0.86.2`,
|
|
1072
|
+
`react-native-reanimated 4.5.3`, `@types/react 19.2.17`.
|
|
1073
|
+
|
|
1074
|
+
Two of those are the app's **intended** versions, not its present ones: BOLDR pins skia
|
|
1075
|
+
**2.6.2** and reanimated **4.5.1** today. Re-check this table when it moves.
|
|
1076
|
+
|
|
1077
|
+
`@types/react` has no 1:1 mapping onto a React patch; 19.2.17 is what BOLDR's own
|
|
1078
|
+
`~19.2.4` resolves to today, so both trees typecheck against the same React types. That
|
|
1079
|
+
is a floating range on the app's side, so it will drift.
|
|
1080
|
+
|
|
1081
|
+
### `engines` and `@types/node`
|
|
1082
|
+
|
|
1083
|
+
`node` is copied verbatim from BOLDR's own field (`^20.19.4 || ^22.13.0 || >=24`) rather
|
|
1084
|
+
than invented, replacing a bare `>=18`. **Nothing in the package or its tests depended on
|
|
1085
|
+
Node 18 semantics, so nothing changed** — and raising the floor legitimises less than it
|
|
1086
|
+
looks like it should: `structuredClone` (17.0), `AbortSignal.timeout` (17.3), global
|
|
1087
|
+
`fetch` (18.0) and `node:module`'s `createRequire` (12.2) were _all_ already available on 18. The only one of them this package actually uses is `createRequire`, in the pixel
|
|
1088
|
+
harness (`src/__tests__/support/canvaskit.ts`).
|
|
1089
|
+
|
|
1090
|
+
`@types/node` tracks the **lowest** supported major (20.x), not the v24 the dev machine
|
|
1091
|
+
runs, so `tsc` rejects a Node 22+/24-only API the declared floor could not provide.
|
|
1092
|
+
|
|
1093
|
+
### Why the skia peer floor is `>=2.8.0`
|
|
1094
|
+
|
|
1095
|
+
It was **unsatisfied by BOLDR when it was set** (the app pinned 2.6.2), and that was the
|
|
1096
|
+
point: once the floor was enforced (next section), the app's `yarn install` failed until
|
|
1097
|
+
skia was bumped. It has since been bumped to 2.8.0, so the app satisfies it and the gate
|
|
1098
|
+
exits 0 — but the bump is not OTA-safe, because 2.8.0 moves the prebuilt binaries into
|
|
1099
|
+
separate packages and therefore needs a native rebuild.
|
|
1100
|
+
|
|
1101
|
+
**Exactly one requirement sets it, and it landed in exactly 2.8.0.** Not 2.7.0 — an
|
|
1102
|
+
earlier note here hedged between the two, and hedging is how a fix gets misattributed.
|
|
1103
|
+
Verified by extracting `lib/module` from every published tarball from 2.6.2 to 2.11.0:
|
|
1104
|
+
|
|
1105
|
+
| Signal | 2.6.2 | 2.6.9 | 2.7.0 | **2.8.0** | … | 2.11.0 |
|
|
1106
|
+
| -------------------------------------------- | ----- | ----- | ----- | --------- | --- | ------ |
|
|
1107
|
+
| `sksg/Container.web.js` `dispose` call sites | 1 | 1 | 1 | **6** | 6 | 6 |
|
|
1108
|
+
| `sksg/Recorder/FrameScope.js` exists | no | no | no | **yes** | ✓ | ✓ |
|
|
1109
|
+
|
|
1110
|
+
Below it the web reconciler leaks a CanvasKit `Paint` per drawn tile per frame into the
|
|
1111
|
+
Emscripten heap — which grows **one way**, because Emscripten never returns pages to the
|
|
1112
|
+
OS. This package's entire memory policy is built on that asymmetry: base _and_ detail
|
|
1113
|
+
tiled to `maxPieceDimension`, `RETIRING` released on the next commit rather than kept,
|
|
1114
|
+
resident bytes booked at 2x. All of it exists because **peak** simultaneous raster bytes,
|
|
1115
|
+
not average, sets a tab's floor for the session. A reconciler that leaks every frame makes
|
|
1116
|
+
that policy unenforceable, so `<2.8.0` is a configuration in which the package's stated
|
|
1117
|
+
contract cannot hold.
|
|
1118
|
+
|
|
1119
|
+
**And nothing after 2.8.0 is required, so the floor is not 2.11.0 just because that is
|
|
1120
|
+
what the dev tree pins.** The web backend is being built against 2.11.0, which invites
|
|
1121
|
+
"then require 2.11.0" — but every file on the web draw path is **byte-identical** from
|
|
1122
|
+
2.8.0 through 2.11.0: `sksg/Container.web.js`, `Reconciler.js`, `HostConfig.js`,
|
|
1123
|
+
`Recorder/FrameScope.js`, web `JsiSkDataFactory.js`, and `types/Image/ColorType.js` /
|
|
1124
|
+
`AlphaType`. Web `JsiSkImageFactory.js` differs only in that 2.10.1 renamed two sibling
|
|
1125
|
+
methods this package never calls (`MakeImageFromTexture` → `MakeImageFromNativeTexture`,
|
|
1126
|
+
`MakeTextureFromImage` → `MakeNativeTextureFromImage`); the `MakeImage` body `ingest.ts`
|
|
1127
|
+
depends on is unchanged. `canvaskit-wasm` is pinned **0.41.0** by every release in the
|
|
1128
|
+
range, so no CanvasKit-side improvement can justify a higher floor either. The other
|
|
1129
|
+
changes in the range are a Reanimated shared-value-**selector** feature (2.11.0,
|
|
1130
|
+
`sksg/utils.js` + `Recorder/Core.js` — this package contributes zero shared values), WebGPU
|
|
1131
|
+
device-API renames, and `Platform.web.resolveAsset` handling an Expo interop object (asset
|
|
1132
|
+
loading; this package never loads an asset).
|
|
1133
|
+
|
|
1134
|
+
**The floor is a memory-correctness claim, not a compile constraint, and it is worth being
|
|
1135
|
+
clear about which.** The full suite — the offscreen CanvasKit pixel tests
|
|
1136
|
+
included — and `tsc --noEmit` both pass against skia **2.8.0** _and_ against **2.6.2**,
|
|
1137
|
+
measured by re-pinning a copy of this tree and reinstalling. So the native path genuinely
|
|
1138
|
+
works on 2.6.2; BOLDR ships it. A peer range is nonetheless a claim about correctness
|
|
1139
|
+
rather than about compilability, and understating one is exactly how the 2.6.2-vs-2.11.0
|
|
1140
|
+
skew survived unnoticed.
|
|
1141
|
+
|
|
1142
|
+
The other three floors moved for a different and duller reason: they were **dead ranges**.
|
|
1143
|
+
Every skia release in range peers `react >=19.0` and `react-native >=0.78`, so `react ^18`
|
|
1144
|
+
and `react-native 0.74`–`0.77` were never satisfiable alongside the skia peer sitting next
|
|
1145
|
+
to them; skia 2.8.0 peers `reanimated >=3.19.1`, so `>=3.0.0` likewise named versions no
|
|
1146
|
+
host could reach. Note where reanimated 4 actually became mandatory for skia: **2.10.0**,
|
|
1147
|
+
which is where the peer becomes `reanimated >=4.0.0` plus `react-native-worklets >=0.7.0`
|
|
1148
|
+
(`npm view @shopify/react-native-skia@2.10.0 peerDependencies`) — _not_ 2.11.0, as an
|
|
1149
|
+
earlier version of this section said. That combination is skia's business, not this
|
|
1150
|
+
package's, but it is why `>=2.8.0` and `reanimated >=3.19.1` can sit next to each other
|
|
1151
|
+
here and still describe a reachable configuration.
|
|
1152
|
+
|
|
1153
|
+
### Enforcing the floor — and what the ecosystem actually does
|
|
1154
|
+
|
|
1155
|
+
**Short answer to "is it standard for a package to fail `yarn install` on a too-low
|
|
1156
|
+
peer?": no.** Measured across all **2020** packages installed in BOLDR, exactly **six**
|
|
1157
|
+
declare any `preinstall`/`install`/`postinstall` hook — `@firebase/util`, `esbuild`,
|
|
1158
|
+
`@sentry/cli`, `@shopify/react-native-skia`, `protobufjs`, `unrs-resolver` — and **none of
|
|
1159
|
+
them checks a peer's version**. Every one is downloading or wiring up its _own_ native
|
|
1160
|
+
asset (skia's `postinstall` is `node scripts/install-libs.js`, which copies its prebuilt
|
|
1161
|
+
libs). Zero use `preinstall`.
|
|
1162
|
+
|
|
1163
|
+
What packages with hard native peers do instead is **declare + assert at runtime**:
|
|
1164
|
+
|
|
1165
|
+
| Mechanism | Who does it | Does it fire? |
|
|
1166
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
1167
|
+
| `peerDependencies` | universal | yarn 1 **warns**; npm (measured 8.19.2) hard-errors `ERESOLVE`; **never** evaluated for `link:` |
|
|
1168
|
+
| runtime `__DEV__` **throw** | skia (`sksg/Container.native.ts`), reanimated (`assertWorkletsVersion`) | yes, on first use — the only layer that catches a drift no install ran |
|
|
1169
|
+
| production `console.error` | skia, once, then degrades to `StaticContainer` | yes |
|
|
1170
|
+
| a shipped semver check script | reanimated (`scripts/validate-worklets-version.js`, `semver` as a real `dependencies` entry) | yes, called from the runtime assert — **not** from an install hook |
|
|
1171
|
+
| `postinstall` peer check | **nobody** | — |
|
|
1172
|
+
|
|
1173
|
+
Two details of reanimated's script are worth copying and are copied here: it **skips
|
|
1174
|
+
prerelease versions entirely** ("The user knows what they're doing"), and it returns
|
|
1175
|
+
`{ok: true}` when no compatibility entry matched — i.e. it **fails open on "cannot tell"**.
|
|
1176
|
+
|
|
1177
|
+
**What yarn 1.22.19 actually runs**, measured with a probe package declaring every hook,
|
|
1178
|
+
installed both ways:
|
|
1179
|
+
|
|
1180
|
+
| | `preinstall` / `install` / `postinstall` | `prepare` | `bin` in `.bin` | package's `peerDependencies` | consumer `postinstall` can fail the install |
|
|
1181
|
+
| ---------------------- | ---------------------------------------- | --------- | --------------- | ---------------------------- | ------------------------------------------- |
|
|
1182
|
+
| tarball / registry dep | **all three run** | no | **yes** | **warns** | yes, exit code preserved |
|
|
1183
|
+
| `link:` dep (BOLDR) | **none run** | no | **no** | silent | yes, exit code preserved |
|
|
1184
|
+
|
|
1185
|
+
So for BOLDR **nothing inside this package can run itself**, and the bin name does not
|
|
1186
|
+
exist. That also **corrects an earlier claim in this README**: `prepare` does _not_ build
|
|
1187
|
+
on install for a linked consumer. It builds when you run `yarn install` in _this_ tree.
|
|
1188
|
+
Keep `yarn dev` running while iterating; a consumer gets whatever `dist/` happens to hold.
|
|
1189
|
+
|
|
1190
|
+
Hence four layers, weakest to strongest:
|
|
1191
|
+
|
|
1192
|
+
1. **`peerDependencies`** — the declarative contract, and the only real gate for a
|
|
1193
|
+
registry consumer. `package.json`'s `//peerDependencies` key is the **single source of
|
|
1194
|
+
truth for the floor**: the other layers parse `>=2.8.0` out of it rather than restating
|
|
1195
|
+
it, so a bump is one line. Keep it a bare `>=x.y.z` — the checker refuses to guess at a
|
|
1196
|
+
union or a caret and degrades to a warning, and a test asserts the shape.
|
|
1197
|
+
2. **`scripts/check-skia-peer.mjs`**, exposed as `bin: pdf-canvas-check-skia` and runnable
|
|
1198
|
+
as `yarn check-peers`. Plain `.mjs` with no dependencies, because under `link:` neither
|
|
1199
|
+
`dist/` nor a declared dependency exists at install time.
|
|
1200
|
+
3. **the consumer's `postinstall`.** BOLDR chains it as
|
|
1201
|
+
`patch-package && node node_modules/@reekon-tools/react-native-pdf-canvas/scripts/check-skia-peer.mjs`
|
|
1202
|
+
— invoked **by path**, because that form works for a `link:` and a tarball consumer
|
|
1203
|
+
alike, whereas the bin name only exists for the latter.
|
|
1204
|
+
4. **a dev-only runtime warning** on the first raster (`src/peer-check.ts`, called from
|
|
1205
|
+
`imageFromPixels`). The only layer that catches a drift no install ran. It **warns
|
|
1206
|
+
rather than throwing**, unlike skia's and reanimated's asserts, because their check is
|
|
1207
|
+
"this feature does not work at all" while ours is a web memory-policy floor that native
|
|
1208
|
+
demonstrably clears at 2.6.2 — see the file header.
|
|
1209
|
+
|
|
1210
|
+
Two things the checker gets right that are easy to get wrong, both measured:
|
|
1211
|
+
|
|
1212
|
+
- **It resolves the CONSUMER's skia, never its own.** Under `link:` the package directory
|
|
1213
|
+
is a sibling checkout _with its own `node_modules`_, so resolving from the script's own
|
|
1214
|
+
location does not fail — it succeeds, answering **2.11.0** while the app ships **2.6.2**.
|
|
1215
|
+
A false pass. Resolution therefore starts at `INIT_CWD` then `process.cwd()`, and a
|
|
1216
|
+
result that landed inside this package while the consumer is elsewhere is reported as
|
|
1217
|
+
"cannot tell", never as "ok".
|
|
1218
|
+
- **Only "too low" is fatal.** A crash in an install-time checker is a broken
|
|
1219
|
+
`yarn install` for the whole app, so a release below the floor exits 1 while an absent
|
|
1220
|
+
skia, an unparseable version, an unparseable floor, a self-resolution and any thrown
|
|
1221
|
+
error all **warn and exit 0**. `--strict` promotes those for CI that owns its layout.
|
|
1222
|
+
A **prerelease** below the floor also only warns, which is not laxity: strict
|
|
1223
|
+
`satisfies('2.12.0-next.1', '>=2.8.0')` is **false**, because a prerelease only satisfies
|
|
1224
|
+
a range naming its own `[major,minor,patch]` tuple — failing an install for being too
|
|
1225
|
+
_new_ is the canonical wrong reason to fail.
|
|
1226
|
+
|
|
1227
|
+
The comparison uses the real `semver` when the tree has one (it does under `link:`, via
|
|
1228
|
+
this package's own dev tree) and a built-in `>=x.y.z` comparator when it does not (a
|
|
1229
|
+
tarball consumer, verified: `loadSemver()` returns null there). Both arms are live in the
|
|
1230
|
+
wild, so `src/__tests__/peer-check.test.ts` pins them and the runtime layer's third
|
|
1231
|
+
implementation to the same table — every skia prerelease published in the range included,
|
|
1232
|
+
with `semver` as referee.
|
|
1233
|
+
|
|
1234
|
+
Measured end to end, yarn 1.22.19, real installs:
|
|
1235
|
+
|
|
1236
|
+
| Consumer | skia | Result |
|
|
1237
|
+
| -------- | -------------- | ----------------------------------------------------------------------- |
|
|
1238
|
+
| `link:` | 2.6.2 | `yarn install` **fails, exit 1**, naming 2.6.2, `>=2.8.0` and the fix |
|
|
1239
|
+
| `link:` | 2.11.0 | clean, exit 0 |
|
|
1240
|
+
| `link:` | absent | warns, **exit 0**; `--strict` → exit 1 |
|
|
1241
|
+
| tarball | 2.6.2 | peer warning **and** the check fails, exit 1 |
|
|
1242
|
+
| tarball | 2.8.0 | clean, exit 0 |
|
|
1243
|
+
| BOLDR | 2.6.2 (before) | `yarn install` failed after `patch-package` applied — fixed by the bump |
|
|
1244
|
+
|
|
1245
|
+
(The same experiments confirm the `optional` flags do their job: neither `react-native` nor
|
|
1246
|
+
`react-native-reanimated` warns when absent.)
|
|
1247
|
+
|
|
1248
|
+
`react-native` and `react-native-reanimated` stay **optional**, and that is load-bearing:
|
|
1249
|
+
the package must load with neither present. That is what lets the pure core, the planner,
|
|
1250
|
+
the cadence policy and the CanvasKit pixel suite run under plain node, and it is why
|
|
1251
|
+
`src/rasterizer/index.native.ts` reaches the native module through a `require()` inside a
|
|
1252
|
+
`try` instead of a static import. Nothing in `src/` imports reanimated at all.
|
|
1253
|
+
|
|
1254
|
+
> Installing `react-native-reanimated` in this dev tree leaves one **expected** unmet
|
|
1255
|
+
> peer: reanimated 4.x requires `react-native-worklets` (`0.10.x - 0.11.x`). It is not
|
|
1256
|
+
> added, because this tree has no Babel or Metro pipeline for a worklet plugin to run in
|
|
1257
|
+
> and nothing here imports reanimated — adding it would trade one warning for its own two
|
|
1258
|
+
> (`@babel/core`, `@react-native/metro-config`). `react-native`'s own
|
|
1259
|
+
> `@react-native/codegen` likewise wants `@babel/core`. Both are inert here.
|
|
1260
|
+
|
|
1261
|
+
## Development
|
|
1262
|
+
|
|
1263
|
+
```bash
|
|
1264
|
+
yarn install
|
|
1265
|
+
yarn typecheck # tsc --noEmit
|
|
1266
|
+
yarn test:run # vitest
|
|
1267
|
+
yarn build # tsc -> dist/
|
|
1268
|
+
yarn dev # tsc --watch, for local linking
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
`react-native` is in the dev tree for its **types**, not to be loaded. Under vitest
|
|
1272
|
+
`require('react-native')` now resolves the file and throws
|
|
1273
|
+
`SyntaxError: Unexpected token 'typeof'` on its Flow syntax, where before it threw
|
|
1274
|
+
`MODULE_NOT_FOUND`. Both land in the same `catch`, so `getDefaultRasterizer()` still
|
|
1275
|
+
returns the self-describing placeholder — but the `try` is now catching a parse error
|
|
1276
|
+
rather than a resolution error, which is worth knowing before someone "tidies" it.
|
|
1277
|
+
|
|
1278
|
+
### The native suites
|
|
1279
|
+
|
|
1280
|
+
Both native backends are React-free libraries with their own test runners, and neither
|
|
1281
|
+
needs an app:
|
|
1282
|
+
|
|
1283
|
+
```bash
|
|
1284
|
+
cd ios && swift test # 82 XCTest cases, ~2s, on the Mac itself
|
|
1285
|
+
|
|
1286
|
+
export JAVA_HOME="/path/to/Android Studio/jbr" # JDK 17
|
|
1287
|
+
cd android && ./gradlew connectedDebugAndroidTest # runs on every attached device
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
`ios/Package.swift` compiles `Sources/PdfCanvasCore` for **macOS**, which is what makes
|
|
1291
|
+
`swift test` possible at all — CoreGraphics and PDFKit exist there and UIKit does not, so
|
|
1292
|
+
a UIKit import anywhere in the core breaks the build immediately. That is the point: the
|
|
1293
|
+
core is not allowed to depend on anything a laptop cannot run.
|
|
1294
|
+
|
|
1295
|
+
`Sources/PdfCanvasBridge` is deliberately outside that target. It needs `<jsi/jsi.h>` and
|
|
1296
|
+
`<React/RCTBridgeModule.h>` from pods SwiftPM cannot see, so its only build is the
|
|
1297
|
+
podspec's and its only proof is a device session — the same shape as Android's
|
|
1298
|
+
`src/reactnative` source set.
|
|
1299
|
+
|
|
1300
|
+
**Driving it on a device** goes through the consuming app's dev probe
|
|
1301
|
+
(`app/dev-pdf-canvas-probe.tsx`), which reads one agreed path so a measurement is
|
|
1302
|
+
repeatable:
|
|
1303
|
+
|
|
1304
|
+
```bash
|
|
1305
|
+
# iOS — the app's own Documents directory. The path carries a per-install UUID, so
|
|
1306
|
+
# the probe reads it at runtime rather than hardcoding it.
|
|
1307
|
+
xcrun devicectl device copy to --device <udid> \
|
|
1308
|
+
--domain-type appDataContainer --domain-identifier com.reekon.boldrpro.dev \
|
|
1309
|
+
--source some.pdf --destination Documents/pdf-canvas-probe.pdf
|
|
1310
|
+
|
|
1311
|
+
# and reach the probe route on a COLD start — a warm deep link is intercepted by the
|
|
1312
|
+
# app's own share-link handler
|
|
1313
|
+
xcrun devicectl device process launch --device <udid> --terminate-existing \
|
|
1314
|
+
--payload-url "boldrpro-dev://dev-pdf-canvas-probe" com.reekon.boldrpro.dev
|
|
1315
|
+
|
|
1316
|
+
# Android
|
|
1317
|
+
adb push some.pdf \
|
|
1318
|
+
/sdcard/Android/data/com.reekon.boldrpro.dev/files/pdf-canvas-probe.pdf
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
`console.log` from a dev build goes to **Metro**, not to the device console, so the
|
|
1322
|
+
`PDFCANVAS-PHASES` lines the probe emits per tile show up in the `expo start` output
|
|
1323
|
+
rather than in `devicectl --console`.
|
|
1324
|
+
|
|
1325
|
+
### Consuming it
|
|
1326
|
+
|
|
1327
|
+
**Both apps take this package as a git dependency over ssh**, and the two package managers
|
|
1328
|
+
need different fragment syntax:
|
|
1329
|
+
|
|
1330
|
+
```jsonc
|
|
1331
|
+
// rock-desktop (yarn Berry) — "#head=" is the unambiguous branch lookup; a bare
|
|
1332
|
+
// fragment is a commit->tag->head fallback, so a tag named main would win.
|
|
1333
|
+
"@reekon-tools/react-native-pdf-canvas":
|
|
1334
|
+
"ssh://git@gitlab.com/reekon-tools/software/rock-pro/react-native-pdf-canvas.git#head=main"
|
|
1335
|
+
|
|
1336
|
+
// rock-pro-app (yarn 1) — a BARE fragment. yarn 1's resolver has no key=value
|
|
1337
|
+
// handling, so "#head=main" is read as a literal ref name and fails.
|
|
1338
|
+
"@reekon-tools/react-native-pdf-canvas":
|
|
1339
|
+
"ssh://git@gitlab.com/reekon-tools/software/rock-pro/react-native-pdf-canvas.git#main"
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
`dist/` is gitignored and absent from the git tree deliberately — the `prepare` script
|
|
1343
|
+
builds it at install time. **Do not remove `prepare`**: yarn 1 gates its
|
|
1344
|
+
clone-install-pack path on `Boolean(scripts.prepare)` and otherwise falls back to
|
|
1345
|
+
`git archive`, which would install the raw repo with zero `dist` files and no obvious
|
|
1346
|
+
error. `prepack` alone covers Berry and is not enough for yarn 1.
|
|
1347
|
+
|
|
1348
|
+
The edit loop is therefore commit, push, then re-resolve in the consumer
|
|
1349
|
+
(`yarn upgrade @reekon-tools/react-native-pdf-canvas`). Yarn 1 caches the packed tarball
|
|
1350
|
+
keyed by resolved sha, so a force-push that reuses a sha serves the stale pack.
|
|
1351
|
+
|
|
1352
|
+
#### The older `link:` arrangement, and why its traps still matter
|
|
1353
|
+
|
|
1354
|
+
Both apps used to link this in from a sibling checkout (`link:../BOLDR_PdfCanvas`). That is
|
|
1355
|
+
no longer how either consumes it, but the two traps below are the reason several comments in
|
|
1356
|
+
this repo and in the apps read the way they do, and they come straight back if anyone goes
|
|
1357
|
+
back to `link:` for local iteration:
|
|
1358
|
+
|
|
1359
|
+
- **The app resolves `dist/`, not `src/`.** A consumer gets whatever `dist/` happens to
|
|
1360
|
+
contain, and a stale one fails as a missing module deep inside the package rather than
|
|
1361
|
+
as anything obvious. **`prepare` does NOT build on the app's install** — measured: yarn
|
|
1362
|
+
1 runs none of a `link:` dependency's lifecycle scripts, so `prepare` only fires on a
|
|
1363
|
+
`yarn install` in _this_ tree. Keep `yarn dev` running while iterating. (This is also
|
|
1364
|
+
why the peer-floor gate is a plain `.mjs` under `scripts/` rather than anything in
|
|
1365
|
+
`dist/` — see "Enforcing the floor".)
|
|
1366
|
+
- **On Windows, yarn 1's `link:` creates a JUNCTION, and Metro will not traverse one.**
|
|
1367
|
+
The bundle fails with `Unable to resolve module @reekon-tools/react-native-pdf-canvas`,
|
|
1368
|
+
which reads exactly like the package was never installed — while Gradle follows the
|
|
1369
|
+
junction fine, so autolinking succeeds and only JS breaks. Replacing it with a real
|
|
1370
|
+
symlink (`mklink /D`) works but is undone by the next `yarn install`. rock-pro-app's
|
|
1371
|
+
`metro.config.js` instead mapped the package name straight to the sibling checkout via
|
|
1372
|
+
`resolver.extraNodeModules`, which survives reinstalls. **That entry is removed now**, and
|
|
1373
|
+
it had to be: with a git dependency the mapping still wins over `node_modules` for anyone
|
|
1374
|
+
holding the checkout, so `tsc` reads the packed copy while Metro bundles the working tree.
|
|
1375
|
+
Its `LOCAL_PACKAGES` list is empty and documents that only `link:` deps belong in it.
|
|
1376
|
+
|
|
1377
|
+
## Design docs
|
|
1378
|
+
|
|
1379
|
+
The reasoning behind every constant and every trap above. **In this repo**, because they
|
|
1380
|
+
describe this package and therefore have to version with it:
|
|
1381
|
+
|
|
1382
|
+
- [`docs/pdf-canvas-package-spec.md`](./docs/pdf-canvas-package-spec.md) — render model,
|
|
1383
|
+
cadence, API, verified traps
|
|
1384
|
+
- [`docs/pdf-canvas-export.md`](./docs/pdf-canvas-export.md) — getting a PDF back out
|
|
1385
|
+
|
|
1386
|
+
**In the `rock-pro-app` repo** under `docs/`, because they describe the Nutrient-removal
|
|
1387
|
+
programme across both consuming apps rather than this package:
|
|
1388
|
+
|
|
1389
|
+
- `pdf-canvas-mvp-plan.md` — phases, spikes, backend decisions
|
|
1390
|
+
- `pdf-canvas-integration.md` — how it meets the shared annotation canvas, and the shared
|
|
1391
|
+
storage line
|