@reekon-tools/react-native-pdf-canvas 0.1.1 → 0.2.0

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