@reekon-tools/react-native-pdf-canvas 0.1.1 → 0.2.1
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/LICENSE +202 -0
- package/NOTICE +57 -0
- package/PdfCanvas.podspec +77 -40
- package/README.md +218 -1316
- package/android/build.gradle +67 -54
- package/android/consumer-rules.pro +7 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
- package/android/src/main/cpp/CMakeLists.txt +54 -0
- package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
- package/android/tools/compile-gate.sh +81 -101
- package/dist/controller.d.ts +14 -0
- package/dist/controller.js +4 -3
- package/dist/rasterizer/index.native.d.ts +8 -12
- package/dist/rasterizer/index.native.js +15 -21
- package/dist/rasterizer/native-bridge.d.ts +51 -75
- package/dist/rasterizer/native-bridge.js +49 -33
- package/dist/rasterizer/native.d.ts +45 -0
- package/dist/rasterizer/native.js +123 -0
- package/dist/react/usePdfDocument.d.ts +5 -5
- package/dist/react/usePdfLayer.d.ts +10 -1
- package/dist/react/usePdfLayer.js +10 -2
- package/dist/testing/scenes.d.ts +3 -3
- package/dist/testing/scenes.js +4 -4
- package/dist/types.d.ts +36 -42
- package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
- package/native/.clangd +5 -0
- package/native/CMakeLists.txt +89 -0
- package/native/core/include/pdfcanvas/document.h +90 -0
- package/native/core/include/pdfcanvas/error.h +47 -0
- package/native/core/include/pdfcanvas/jsi.h +18 -0
- package/native/core/include/pdfcanvas/jsi_config.h +30 -0
- package/native/core/include/pdfcanvas/library.h +28 -0
- package/native/core/include/pdfcanvas/pixels.h +92 -0
- package/native/core/include/pdfcanvas/service.h +87 -0
- package/native/core/include/pdfcanvas/slots.h +113 -0
- package/native/core/include/pdfcanvas/types.h +110 -0
- package/native/core/pdfcanvas-core.cmake +22 -0
- package/native/core/src/document.cpp +516 -0
- package/native/core/src/error.cpp +29 -0
- package/native/core/src/jsi.cpp +66 -0
- package/native/core/src/library.cpp +52 -0
- package/native/core/src/pixels.cpp +45 -0
- package/native/core/src/service.cpp +156 -0
- package/native/core/src/slots.cpp +139 -0
- package/native/tests/fixtures.cpp +451 -0
- package/native/tests/fixtures.h +96 -0
- package/native/tests/harness.h +122 -0
- package/native/tests/main.cpp +64 -0
- package/native/tests/pixels_util.h +126 -0
- package/native/tests/test_document.cpp +485 -0
- package/native/tests/test_service.cpp +130 -0
- package/native/tests/test_slots.cpp +250 -0
- package/package.json +27 -7
- package/react-native.config.js +5 -2
- package/scripts/fetch-pdfium.mjs +559 -0
- package/scripts/pdfium-manifest.json +48 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
- package/android/src/jsi/cpp/CMakeLists.txt +0 -33
- package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
- package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
- package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
- package/dist/rasterizer/android.d.ts +0 -121
- package/dist/rasterizer/android.js +0 -126
- package/dist/rasterizer/ios.d.ts +0 -84
- package/dist/rasterizer/ios.js +0 -155
- package/ios/Package.swift +0 -58
- package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
- package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
- package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
- package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
- package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
- package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
- package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
- package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
- package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
- package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
- package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
- package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
- package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
- package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
- package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
- package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
- package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
- package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
package/README.md
CHANGED
|
@@ -1,124 +1,162 @@
|
|
|
1
1
|
# @reekon-tools/react-native-pdf-canvas
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@reekon-tools/react-native-pdf-canvas)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Renders PDF pages as Skia images positioned at exact PDF-space rectangles within a
|
|
7
|
+
host-supplied transform. A single PDFium engine serves iOS, Android and web, so a given tile is
|
|
8
|
+
byte-identical across all three platforms.
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
The package does not provide a PDF viewer. It owns no `<Canvas>`, no gesture handlers, no
|
|
13
|
+
viewport state and no document model; each of those remains the responsibility of the host
|
|
14
|
+
application. What it provides is rasterized page tiles, each paired with the document-space
|
|
15
|
+
rectangle it occupies, for the host to draw inside its own transform alongside its own content.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<Canvas>
|
|
19
|
+
<Group transform={worldTransform}>
|
|
20
|
+
<PdfContentView content={content} />
|
|
21
|
+
{/* Host content: annotations, measurements, overlays — same doc space, same transform */}
|
|
22
|
+
</Group>
|
|
23
|
+
</Canvas>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Conventional PDF components encapsulate the scroll view, the zoom transform and the rendered
|
|
27
|
+
output. Content drawn above such a component must be registered against its internal viewport,
|
|
28
|
+
and that registration is approximate. This package inverts the arrangement: it emits rectangles
|
|
29
|
+
in PDF points and never reads the host's transform. Four properties follow.
|
|
30
|
+
|
|
31
|
+
- **Exact registration.** A single transform is applied once, to the page and to the host's
|
|
32
|
+
content alike. No error term is introduced, because the package performs no conversion to
|
|
33
|
+
screen space.
|
|
34
|
+
- **No contention for the frame budget.** The package contributes zero Reanimated shared values
|
|
35
|
+
to the Skia subtree, so a host driving its world transform from the UI thread retains a
|
|
36
|
+
per-frame budget of one.
|
|
37
|
+
- **Deferred rasterization.** Rendering occurs when the host reports that its viewport has
|
|
38
|
+
settled, rather than on every frame. A stale tile is soft but never mispositioned, which is
|
|
39
|
+
the property that makes deferral safe.
|
|
40
|
+
- **Bounded memory at any zoom level.** Detail tiles track the viewport rather than the page, so
|
|
41
|
+
a 36-inch sheet at 8× magnification costs no more than the same sheet at 1×.
|
|
42
|
+
|
|
43
|
+
### Intended use
|
|
44
|
+
|
|
45
|
+
React Native and web applications that render their own content above a PDF and require it to
|
|
46
|
+
register exactly: markup and annotation tools, field measurement, takeoff and estimating, CAD
|
|
47
|
+
and drawing review, and form overlays.
|
|
48
|
+
|
|
49
|
+
### Out of scope
|
|
50
|
+
|
|
51
|
+
The package supplies no viewer chrome — no scroll view, page controls or navigation — and
|
|
52
|
+
deliberately requires the host to provide the canvas, the gesture handling and the page layout.
|
|
53
|
+
It does not extract text, perform search, or modify documents; its function is rasterization.
|
|
54
|
+
|
|
55
|
+
## Project status
|
|
56
|
+
|
|
57
|
+
Version 0.2.0 is a pre-release. The core, the cadence controller, the React layer and a
|
|
58
|
+
deterministic fake backend are implemented and tested. All three platform backends render with
|
|
59
|
+
PDFium, so annotations, form fields, passwords and page rotation behave identically on each. The
|
|
60
|
+
native core is covered by a host-machine C++ suite, and the web backend by 45 tests driving real
|
|
61
|
+
PDFium WebAssembly under Node.
|
|
62
|
+
|
|
63
|
+
Two areas remain unverified: the web backend has not yet been exercised in a browser, and the
|
|
64
|
+
iOS binding requires a device pass.
|
|
65
|
+
|
|
66
|
+
## Requirements
|
|
67
|
+
|
|
68
|
+
| Peer dependency | Range | Required |
|
|
69
|
+
| ---------------------------- | ---------- | ------------------------------------------ |
|
|
70
|
+
| `react` | `>=19.0.0` | Always |
|
|
71
|
+
| `@shopify/react-native-skia` | `>=2.8.0` | Always |
|
|
72
|
+
| `react-native` | `>=0.78.0` | Native platforms only |
|
|
73
|
+
| `react-native-reanimated` | `>=3.19.1` | Only if the host transform is driven by it |
|
|
74
|
+
| `@embedpdf/pdfium` | `>=2.15.0` | Web only |
|
|
75
|
+
|
|
76
|
+
PDFium is not an npm dependency. It is retrieved at build time from
|
|
77
|
+
[pdfium-binaries](https://github.com/bblanchon/pdfium-binaries), pinned by SHA-256 digest in
|
|
78
|
+
`scripts/pdfium-manifest.json`, and adds approximately 6.5 MB per platform. iOS retrieves it
|
|
79
|
+
during CocoaPods podspec evaluation; Android retrieves it from a Gradle task. Air-gapped builds
|
|
80
|
+
may direct `PDFCANVAS_PDFIUM_CACHE` or `PDFCANVAS_PDFIUM_MIRROR` at a local copy, which is still
|
|
81
|
+
verified against the manifest.
|
|
82
|
+
|
|
83
|
+
The prebuilt engine sets the native floors: **iOS 17.0** (`iosMinimumOSVersion` in the manifest,
|
|
84
|
+
declared by the pod and checked against the dylib at fetch time) and **Android API 24**. A host app
|
|
85
|
+
with a lower iOS deployment target fails at `pod install`; raise the target rather than the
|
|
86
|
+
framework's plist, since the dylib will not load on older systems either.
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
npm install @reekon-tools/react-native-pdf-canvas
|
|
54
92
|
```
|
|
55
93
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
The platform backend is not registered automatically. It resides on a dedicated subpath so that
|
|
95
|
+
importing the package never introduces a native module into a web or test bundle.
|
|
96
|
+
|
|
97
|
+
### iOS and Android
|
|
60
98
|
|
|
61
|
-
|
|
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.
|
|
99
|
+
Autolinking handles the native module. Register the backend once, during application startup:
|
|
67
100
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
```ts
|
|
102
|
+
import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
|
|
103
|
+
import {getDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
|
|
104
|
+
|
|
105
|
+
setDefaultRasterizer(getDefaultRasterizer());
|
|
106
|
+
```
|
|
71
107
|
|
|
72
|
-
|
|
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.
|
|
108
|
+
Then run `pod install` in the `ios` directory.
|
|
76
109
|
|
|
77
|
-
### Web
|
|
110
|
+
### Web
|
|
111
|
+
|
|
112
|
+
Web configuration requires three steps, performed once before the first render.
|
|
113
|
+
|
|
114
|
+
**1. Load CanvasKit.** This step may be omitted by hosts that already load CanvasKit themselves,
|
|
115
|
+
whether through `WithSkiaWeb`, RN Skia's `LoadSkiaWeb`, or a `<script>` tag that assigns
|
|
116
|
+
`globalThis.CanvasKit`; the package detects any of these. `loadPdfCanvasSkiaWeb` is the
|
|
117
|
+
awaitable equivalent:
|
|
78
118
|
|
|
79
119
|
```ts
|
|
80
120
|
import {loadPdfCanvasSkiaWeb} from '@reekon-tools/react-native-pdf-canvas/web-init';
|
|
81
121
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// tree installed.
|
|
122
|
+
// The host supplies the locator. The package never hardcodes a CanvasKit URL, because
|
|
123
|
+
// the version that must match is the one installed in the host's dependency tree.
|
|
85
124
|
await loadPdfCanvasSkiaWeb({
|
|
86
125
|
locateFile: file =>
|
|
87
126
|
`https://cdn.jsdelivr.net/npm/canvaskit-wasm@${canvasKitVersion}/bin/full/${file}`,
|
|
88
127
|
});
|
|
89
128
|
```
|
|
90
129
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
130
|
+
**2. Configure the PDFium worker.** The package ships the worker but constructs neither a
|
|
131
|
+
`Worker` nor a URL of its own, as worker bundling and the location of `pdfium.wasm` are
|
|
132
|
+
properties of the host's build pipeline. Under Vite:
|
|
103
133
|
|
|
104
|
-
|
|
134
|
+
```ts
|
|
135
|
+
import PdfCanvasWorker from '@reekon-tools/react-native-pdf-canvas/worker?worker';
|
|
136
|
+
import wasmUrl from '@embedpdf/pdfium/pdfium.wasm?url';
|
|
137
|
+
import {configureWebPdfium} from '@reekon-tools/react-native-pdf-canvas/rasterizer';
|
|
138
|
+
import {setDefaultRasterizer} from '@reekon-tools/react-native-pdf-canvas';
|
|
105
139
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
setDefaultRasterizer(
|
|
141
|
+
configureWebPdfium({
|
|
142
|
+
createWorker: () => new PdfCanvasWorker(),
|
|
143
|
+
wasm: {url: wasmUrl},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
```
|
|
110
147
|
|
|
111
|
-
|
|
148
|
+
Any other means of producing a **module** worker is equally acceptable, including
|
|
149
|
+
`new Worker(url, {type: 'module'})`. The `wasm` option also accepts `{binary}` for hosts with
|
|
150
|
+
their own asset pipeline, at the cost of a 4.6 MB structured clone per document.
|
|
112
151
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
152
|
+
**3. Do not wrap `PdfContentView` in `WithSkiaWeb`.** The component mounts within the host's
|
|
153
|
+
`<Group>` inside the host's `<Canvas>`, where a lazy component boundary would break the Skia
|
|
154
|
+
subtree. A `WithSkiaWeb` boundary belongs around the canvas as a whole.
|
|
118
155
|
|
|
119
|
-
|
|
120
|
-
`
|
|
121
|
-
|
|
156
|
+
If configuration is omitted, every `openPdfDocument` and `usePdfDocument` call fails with a
|
|
157
|
+
descriptive `unsupported: No PageRasterizer available` error rather than degrading silently.
|
|
158
|
+
`createFakeRasterizer()`, exported from the `./testing` subpath, keeps the remainder of the API
|
|
159
|
+
usable under test.
|
|
122
160
|
|
|
123
161
|
## Usage
|
|
124
162
|
|
|
@@ -131,11 +169,11 @@ import {
|
|
|
131
169
|
} from '@reekon-tools/react-native-pdf-canvas';
|
|
132
170
|
|
|
133
171
|
function Sheet({uri, worldTransform, viewport}) {
|
|
134
|
-
//
|
|
172
|
+
// Both hooks must be mounted above the <Canvas>. See "Hook placement".
|
|
135
173
|
const {document} = usePdfDocument({uri});
|
|
136
174
|
const {content, controller} = usePdfLayer({document});
|
|
137
175
|
|
|
138
|
-
//
|
|
176
|
+
// Gesture handling and the transform remain the host's. Report settle events.
|
|
139
177
|
const pan = Gesture.Pan()
|
|
140
178
|
.onBegin(() => runOnJS(controller.suppress)())
|
|
141
179
|
.onFinalize(() => runOnJS(controller.settle)(viewport()));
|
|
@@ -145,7 +183,7 @@ function Sheet({uri, worldTransform, viewport}) {
|
|
|
145
183
|
<Canvas style={{flex: 1}}>
|
|
146
184
|
<Group transform={worldTransform}>
|
|
147
185
|
<PdfContentView content={content} />
|
|
148
|
-
{/*
|
|
186
|
+
{/* Remaining host content, in the same doc space */}
|
|
149
187
|
</Group>
|
|
150
188
|
</Canvas>
|
|
151
189
|
</GestureDetector>
|
|
@@ -153,1239 +191,103 @@ function Sheet({uri, worldTransform, viewport}) {
|
|
|
153
191
|
}
|
|
154
192
|
```
|
|
155
193
|
|
|
156
|
-
|
|
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.
|
|
194
|
+
`viewport()` returns a plain `{visibleDocRect, scale}` snapshot. The package never reads the
|
|
195
|
+
host's shared values; the host pushes the snapshot.
|
|
329
196
|
|
|
330
|
-
|
|
331
|
-
import {
|
|
332
|
-
DEFAULT_POLICY,
|
|
333
|
-
resolvePolicy,
|
|
334
|
-
} from '@reekon-tools/react-native-pdf-canvas';
|
|
197
|
+
### Viewport cadence
|
|
335
198
|
|
|
336
|
-
|
|
337
|
-
|
|
199
|
+
The host is required to report when its viewport settles. The package cannot determine this
|
|
200
|
+
independently: a `withDecay` fling animates for approximately 38 seconds at Reanimated's default
|
|
201
|
+
deceleration, and no reliable means exists for a library to observe a consumer's animation from
|
|
202
|
+
outside it.
|
|
338
203
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|
|
204
|
+
| Method | Called on |
|
|
205
|
+
| ----------------------- | -------------------------------------------------- |
|
|
206
|
+
| `controller.suppress()` | Gesture begin |
|
|
207
|
+
| `controller.settle(v)` | Gesture end, or an animation's completion callback |
|
|
208
|
+
| `controller.hint(v)` | Continuous input: wheel, trackpad, momentum |
|
|
425
209
|
|
|
426
|
-
|
|
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';
|
|
210
|
+
`hint` is debounced behind a trailing quiet timer. `settle` takes effect immediately.
|
|
431
211
|
|
|
432
|
-
|
|
433
|
-
configureWebPdfium({
|
|
434
|
-
createWorker: () => new PdfCanvasWorker(),
|
|
435
|
-
wasm: {url: wasmUrl},
|
|
436
|
-
}),
|
|
437
|
-
);
|
|
438
|
-
```
|
|
212
|
+
### Hook placement
|
|
439
213
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
```
|
|
214
|
+
Both hooks must be mounted above the `<Canvas>`. React context does not cross the canvas
|
|
215
|
+
boundary, as RN Skia mounts a separate reconciler for the Skia subtree, and a magnifier loupe
|
|
216
|
+
constitutes a second canvas with its own copy of every hook. A cache instantiated inside the
|
|
217
|
+
canvas is therefore created twice, concurrently, doubling tens of megabytes. Hoisting the hook
|
|
218
|
+
and passing the same `content` object to both canvases is what makes a loupe free.
|
|
623
219
|
|
|
624
|
-
|
|
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
|
-
```
|
|
220
|
+
## API reference
|
|
875
221
|
|
|
876
|
-
|
|
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
|
-
```
|
|
222
|
+
### Entry points
|
|
1270
223
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
`
|
|
1275
|
-
|
|
1276
|
-
|
|
224
|
+
| Subpath | Exports |
|
|
225
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
226
|
+
| `.` (root) | `usePdfDocument`, `usePdfLayer`, `PdfContentView`, `openPdfDocument`, `createPdfController`, layouts, policy, types |
|
|
227
|
+
| `./rasterizer` | `getDefaultRasterizer`, `configureWebPdfium` |
|
|
228
|
+
| `./web-init` | `loadPdfCanvasSkiaWeb()` — web only |
|
|
229
|
+
| `./worker` | The PDFium module worker; a bundler entry point, web only |
|
|
230
|
+
| `./skia` | `ingestRaster`, `imageFromPixels`, `rasterByteLength`, `skiaApi()` |
|
|
231
|
+
| `./testing` | `createFakeRasterizer()` and the deterministic scenes |
|
|
1277
232
|
|
|
1278
|
-
|
|
233
|
+
The root barrel contains no module-scope Skia, which permits it to be evaluated in Node during a
|
|
234
|
+
server-side rendering pass. The Skia ingest seam is reached through the `./skia` subpath.
|
|
1279
235
|
|
|
1280
|
-
|
|
1281
|
-
needs an app:
|
|
236
|
+
### Page layouts
|
|
1282
237
|
|
|
1283
|
-
|
|
1284
|
-
|
|
238
|
+
`singlePage()`, `continuousVertical(gap)` and `spread(gap)` are provided. A host may supply any
|
|
239
|
+
function of the form `(pages: DocSize[]) => DocRect[]`.
|
|
1285
240
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
241
|
+
### Render policy
|
|
242
|
+
|
|
243
|
+
Cadence is configured through a partial `RasterPolicy`. Every constant has an exported default:
|
|
1289
244
|
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
|
245
|
+
```ts
|
|
246
|
+
usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});
|
|
1319
247
|
```
|
|
1320
248
|
|
|
1321
|
-
`
|
|
1322
|
-
|
|
1323
|
-
|
|
249
|
+
`minEpochIntervalMs` (default 300) should be measured before production use and set to the
|
|
250
|
+
observed p95 raster time. A value set too low permits a user adjusting the zoom to supersede
|
|
251
|
+
each render before it completes.
|
|
1324
252
|
|
|
1325
|
-
|
|
253
|
+
## Platform support
|
|
1326
254
|
|
|
1327
|
-
|
|
1328
|
-
|
|
255
|
+
| Platform | Engine | Backend identifier |
|
|
256
|
+
| -------- | --------------------------------------------- | ------------------ |
|
|
257
|
+
| iOS | PDFium via `PDFium.xcframework` | `ios-pdfium` |
|
|
258
|
+
| Android | PDFium via `libpdfium.so` | `android-pdfium` |
|
|
259
|
+
| Web | PDFium WebAssembly in a module worker | `web-pdfium` |
|
|
260
|
+
| Any | `createFakeRasterizer()`, for tests and demos | Configurable |
|
|
1329
261
|
|
|
1330
|
-
|
|
1331
|
-
|
|
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"
|
|
262
|
+
All three platform backends link the same pinned PDFium release. Annotations, form fields,
|
|
263
|
+
passwords and the `/Rotate` page attribute are therefore handled identically on each.
|
|
1335
264
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
"ssh://git@gitlab.com/reekon-tools/software/rock-pro/react-native-pdf-canvas.git#main"
|
|
1340
|
-
```
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE) for the full terms.
|
|
1341
268
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
|
269
|
+
The package may be used in commercial and closed-source software. Redistribution requires
|
|
270
|
+
retaining the copyright notice and the attributions in [NOTICE](./NOTICE), and stating any
|
|
271
|
+
modifications made to the files.
|
|
272
|
+
|
|
273
|
+
### Third-party components
|
|
274
|
+
|
|
275
|
+
Because this package builds against PDFium, an application that ships it also ships PDFium and
|
|
276
|
+
its bundled components, licensed variously under BSD-3-Clause, MIT, Apache-2.0, the FreeType
|
|
277
|
+
License, the zlib License and the IJG License. [NOTICE](./NOTICE) enumerates the attributions to
|
|
278
|
+
reproduce in an application's third-party notices. Complete license texts are included in the
|
|
279
|
+
`licenses/` directory of every PDFium archive retrieved during the build.
|
|
280
|
+
|
|
281
|
+
## Internals
|
|
282
|
+
|
|
283
|
+
The rationale behind each constant, each measured figure and each upstream defect the package
|
|
284
|
+
works around is documented in `CLAUDE.md` in the repository, alongside extended design
|
|
285
|
+
documentation under `docs/`. Neither is included in the published tarball. The relevant section
|
|
286
|
+
should be consulted before altering behavior it describes.
|
|
287
|
+
|
|
288
|
+
```sh
|
|
289
|
+
yarn install
|
|
290
|
+
yarn ci # Format check, typecheck, tests, build
|
|
291
|
+
yarn pdfium:fetch host # Retrieve and verify PDFium for the local machine
|
|
292
|
+
yarn native:test # Build and run the C++ core suite locally
|
|
293
|
+
```
|