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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/PdfCanvas.podspec +80 -0
  2. package/README.md +1391 -0
  3. package/android/build.gradle +175 -0
  4. package/android/consumer-rules.pro +3 -0
  5. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  6. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  7. package/android/gradle.properties +2 -0
  8. package/android/gradlew +248 -0
  9. package/android/gradlew.bat +98 -0
  10. package/android/settings.gradle +34 -0
  11. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +226 -0
  12. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +239 -0
  13. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +251 -0
  14. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +221 -0
  15. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +783 -0
  16. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +1388 -0
  17. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +98 -0
  18. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +324 -0
  19. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +775 -0
  20. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +20 -0
  21. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +558 -0
  22. package/android/src/jsi/cpp/CMakeLists.txt +33 -0
  23. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +469 -0
  24. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +140 -0
  25. package/android/src/main/AndroidManifest.xml +7 -0
  26. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +45 -0
  27. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +564 -0
  28. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +33 -0
  29. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +277 -0
  30. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +214 -0
  31. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +30 -0
  32. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +234 -0
  33. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +47 -0
  34. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +143 -0
  35. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +95 -0
  36. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +626 -0
  37. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasPackage.java +33 -0
  38. package/android/tools/compile-gate.sh +289 -0
  39. package/dist/cache.d.ts +161 -0
  40. package/dist/cache.js +389 -0
  41. package/dist/clock.d.ts +21 -0
  42. package/dist/clock.js +31 -0
  43. package/dist/controller.d.ts +131 -0
  44. package/dist/controller.js +1002 -0
  45. package/dist/geometry.d.ts +96 -0
  46. package/dist/geometry.js +248 -0
  47. package/dist/index.d.ts +52 -0
  48. package/dist/index.js +50 -0
  49. package/dist/index.native.d.ts +21 -0
  50. package/dist/index.native.js +23 -0
  51. package/dist/index.web.d.ts +24 -0
  52. package/dist/index.web.js +26 -0
  53. package/dist/layout.d.ts +78 -0
  54. package/dist/layout.js +152 -0
  55. package/dist/peer-check.d.ts +83 -0
  56. package/dist/peer-check.js +183 -0
  57. package/dist/planner.d.ts +35 -0
  58. package/dist/planner.js +530 -0
  59. package/dist/policy.d.ts +14 -0
  60. package/dist/policy.js +168 -0
  61. package/dist/rasterizer/android.d.ts +121 -0
  62. package/dist/rasterizer/android.js +126 -0
  63. package/dist/rasterizer/buffer.d.ts +18 -0
  64. package/dist/rasterizer/buffer.js +18 -0
  65. package/dist/rasterizer/fake.d.ts +92 -0
  66. package/dist/rasterizer/fake.js +433 -0
  67. package/dist/rasterizer/index.d.ts +38 -0
  68. package/dist/rasterizer/index.js +69 -0
  69. package/dist/rasterizer/index.native.d.ts +19 -0
  70. package/dist/rasterizer/index.native.js +80 -0
  71. package/dist/rasterizer/index.web.d.ts +63 -0
  72. package/dist/rasterizer/index.web.js +75 -0
  73. package/dist/rasterizer/ingest.d.ts +180 -0
  74. package/dist/rasterizer/ingest.js +307 -0
  75. package/dist/rasterizer/ios.d.ts +84 -0
  76. package/dist/rasterizer/ios.js +155 -0
  77. package/dist/rasterizer/native-bridge.d.ts +261 -0
  78. package/dist/rasterizer/native-bridge.js +413 -0
  79. package/dist/rasterizer/web/client.d.ts +84 -0
  80. package/dist/rasterizer/web/client.js +359 -0
  81. package/dist/rasterizer/web/engine.d.ts +69 -0
  82. package/dist/rasterizer/web/engine.js +308 -0
  83. package/dist/rasterizer/web/index.d.ts +26 -0
  84. package/dist/rasterizer/web/index.js +21 -0
  85. package/dist/rasterizer/web/pdfium.d.ts +213 -0
  86. package/dist/rasterizer/web/pdfium.js +170 -0
  87. package/dist/rasterizer/web/protocol.d.ts +138 -0
  88. package/dist/rasterizer/web/protocol.js +37 -0
  89. package/dist/rasterizer/web/session.d.ts +106 -0
  90. package/dist/rasterizer/web/session.js +413 -0
  91. package/dist/rasterizer/web/worker.d.ts +44 -0
  92. package/dist/rasterizer/web/worker.js +76 -0
  93. package/dist/react/PdfContentView.d.ts +98 -0
  94. package/dist/react/PdfContentView.js +189 -0
  95. package/dist/react/index.d.ts +6 -0
  96. package/dist/react/index.js +3 -0
  97. package/dist/react/usePdfDocument.d.ts +134 -0
  98. package/dist/react/usePdfDocument.js +275 -0
  99. package/dist/react/usePdfLayer.d.ts +94 -0
  100. package/dist/react/usePdfLayer.js +388 -0
  101. package/dist/skia/index.d.ts +26 -0
  102. package/dist/skia/index.js +25 -0
  103. package/dist/skia/index.native.d.ts +14 -0
  104. package/dist/skia/index.native.js +16 -0
  105. package/dist/skia/index.web.d.ts +12 -0
  106. package/dist/skia/index.web.js +14 -0
  107. package/dist/skia/resolve.native.d.ts +18 -0
  108. package/dist/skia/resolve.native.js +22 -0
  109. package/dist/skia/resolve.web.d.ts +41 -0
  110. package/dist/skia/resolve.web.js +62 -0
  111. package/dist/skia/skia-api.d.ts +94 -0
  112. package/dist/skia/skia-api.js +113 -0
  113. package/dist/testing/index.d.ts +27 -0
  114. package/dist/testing/index.js +28 -0
  115. package/dist/testing/scenes.d.ts +100 -0
  116. package/dist/testing/scenes.js +267 -0
  117. package/dist/types.d.ts +611 -0
  118. package/dist/types.js +21 -0
  119. package/dist/web-init.d.ts +68 -0
  120. package/dist/web-init.js +64 -0
  121. package/ios/Package.swift +58 -0
  122. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +743 -0
  123. package/ios/Sources/PdfCanvasBridge/include/PdfCanvasModule.h +36 -0
  124. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +38 -0
  125. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +389 -0
  126. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +57 -0
  127. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +44 -0
  128. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +82 -0
  129. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +37 -0
  130. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +158 -0
  131. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +80 -0
  132. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +65 -0
  133. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +548 -0
  134. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +48 -0
  135. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +26 -0
  136. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +122 -0
  137. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +80 -0
  138. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +79 -0
  139. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +129 -0
  140. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +100 -0
  141. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +65 -0
  142. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +111 -0
  143. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +85 -0
  144. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +163 -0
  145. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +297 -0
  146. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +140 -0
  147. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +358 -0
  148. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +262 -0
  149. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +173 -0
  150. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +114 -0
  151. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +158 -0
  152. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +544 -0
  153. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +287 -0
  154. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +396 -0
  155. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +639 -0
  156. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +179 -0
  157. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +283 -0
  158. package/package.json +144 -0
  159. package/react-native.config.js +40 -0
  160. package/scripts/check-skia-peer.mjs +86 -0
  161. package/scripts/skia-peer-check.mjs +363 -0
@@ -0,0 +1,189 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The ONE component.
4
+ *
5
+ * It draws whatever `content` says, at the doc-space rects `content` carries, and
6
+ * it does nothing else. No gestures, no viewport, no shared values, no `<Canvas>`.
7
+ * It is meant to be mounted inside SOMEBODY ELSE'S `<Group transform={...}>`:
8
+ *
9
+ * <Canvas>
10
+ * <Group transform={worldTransform}>
11
+ * <PdfContentView content={content} />
12
+ * ...the host's own annotation layers...
13
+ * </Group>
14
+ * </Canvas>
15
+ *
16
+ * THE ONE RULE: the transform decides WHEN to rasterize, never WHAT to draw. Every
17
+ * number this component hands to Skia is a plain JS number read off a frozen
18
+ * `PdfContent`, so a pan or a zoom re-records the host's scene without this
19
+ * subtree changing at all.
20
+ */
21
+ import { Children, isValidElement, useEffect, useMemo } from 'react';
22
+ /*
23
+ * LEAF IMPORTS, NOT THE BARREL — and this is load-bearing, not fussiness.
24
+ *
25
+ * `import {Group, Image, Rect, Paint} from '@shopify/react-native-skia'` is a
26
+ * VALUE import of a package whose barrel is
27
+ * `export * from "./skia"` -> `export {Skia} from "./Skia"` -> on web,
28
+ * `export const Skia = JsiSkApi(global.CanvasKit)`. RN Skia declares no
29
+ * `sideEffects: false` and no `exports` map, so no bundler may drop that
30
+ * evaluation, and it is taken ONCE: reach it before `LoadSkiaWeb` resolves and
31
+ * the api is permanently broken (see `../skia/skia-api.ts`).
32
+ *
33
+ * That one line was the last thing keeping the package ROOT from being safe to
34
+ * evaluate during rock-desktop's SSR pass — `PdfContentView` is on the root
35
+ * barrel, so naming the package named `Skia.web.js`. MEASURED with a Vite build,
36
+ * not inferred: a bundle of this file used to contain
37
+ * `JsiSkApi(global.CanvasKit)` and now does not, while a bundle of the bare
38
+ * specifier still does.
39
+ *
40
+ * The five modules below are the leaves the barrel would have reached anyway.
41
+ * Each is a handful of lines that imports nothing but `react` (the components)
42
+ * or nothing at all (the enums), so this costs no bundle size on either
43
+ * platform, and on web the module instance is IDENTICAL to the barrel's —
44
+ * `lib/module/index.js` -> `renderer/index.js` -> `renderer/components/index.js`
45
+ * is the same file graph. Legal because RN Skia publishes `lib/**` and declares
46
+ * no `exports` map; same standing as boldr-utils' deep import of
47
+ * `@shopify/react-native-skia/lib/module/web/index.js` for `WithSkiaWeb`.
48
+ *
49
+ * `GroupProps` / `SamplingOptions` stay on the bare specifier because
50
+ * `import type` is erased entirely and reaches no module at runtime.
51
+ */
52
+ import { Group } from '@shopify/react-native-skia/lib/module/renderer/components/Group.js';
53
+ import { Paint } from '@shopify/react-native-skia/lib/module/renderer/components/Paint.js';
54
+ import { Image } from '@shopify/react-native-skia/lib/module/renderer/components/image/Image.js';
55
+ import { Rect } from '@shopify/react-native-skia/lib/module/renderer/components/shapes/Rect.js';
56
+ import { FilterMode, MipmapMode, } from '@shopify/react-native-skia/lib/module/skia/types/Image/Image.js';
57
+ /* ------------------------------------------------------------------ *
58
+ * Sampling
59
+ * ------------------------------------------------------------------ */
60
+ /*
61
+ * One frozen object per variant, created ONCE at module scope.
62
+ *
63
+ * RN Skia's `shallowEq` is REFERENCE equality on every prop, and `prepareUpdate`
64
+ * returns null when the props compare equal. An inline `{filter: FilterMode.Linear}`
65
+ * is a fresh object on every render, so every single render of every single
66
+ * <Image> would be treated as a real prop change and re-enter the node's update
67
+ * path — the exact per-frame cost this package exists to avoid.
68
+ */
69
+ const SAMPLING_LINEAR = Object.freeze({
70
+ filter: FilterMode.Linear,
71
+ // Explicitly None. See the `sampling` JSDoc: mipmaps are unreachable here.
72
+ mipmap: MipmapMode.None,
73
+ });
74
+ const SAMPLING_NEAREST = Object.freeze({
75
+ filter: FilterMode.Nearest,
76
+ mipmap: MipmapMode.None,
77
+ });
78
+ /** Mitchell (B=C=1/3) — the standard "photographic" cubic, and what Skia's own
79
+ * `MitchellCubicSampling` uses. Frozen locally so we never hand a mutable object
80
+ * owned by the Skia package into a memoised prop. */
81
+ const SAMPLING_CUBIC = Object.freeze({ B: 1 / 3, C: 1 / 3 });
82
+ const SAMPLING = Object.freeze({
83
+ linear: SAMPLING_LINEAR,
84
+ nearest: SAMPLING_NEAREST,
85
+ cubic: SAMPLING_CUBIC,
86
+ });
87
+ const IS_DEV = typeof __DEV__ !== 'undefined'
88
+ ? __DEV__ === true
89
+ : typeof process !== 'undefined' &&
90
+ process.env?.['NODE_ENV'] !== 'production';
91
+ /**
92
+ * Is this child RN Skia's `<Paint>`? Identity first, NAME as a fallback.
93
+ *
94
+ * The fallback is the price of the leaf import above, and it is only owed on
95
+ * NATIVE: Metro resolves `@shopify/react-native-skia` through its `react-native`
96
+ * main field to `src/index.ts`, so a host's `<Paint>` comes from `src/renderer/`
97
+ * while this file's comes from `lib/module/renderer/`. Two different function
98
+ * objects for the same component, and `===` misses. On web (and under vitest,
99
+ * whose shim is backed by the same `lib/module` tree) both sides resolve to the
100
+ * one module and identity is exact.
101
+ *
102
+ * Both halves are cheap to be wrong about: this feeds a `__DEV__` console
103
+ * warning about a documented misuse, so a false negative loses a warning and a
104
+ * false positive (a host component of the host's own that happens to be named
105
+ * `Paint`) prints one that names exactly what it saw. Neither changes what is
106
+ * drawn. That is the whole reason a name check is acceptable here and would not
107
+ * be anywhere the render depends on it.
108
+ */
109
+ function isPaintNode(type) {
110
+ if (type === Paint) {
111
+ return true;
112
+ }
113
+ return (typeof type === 'function' && type.name === 'Paint');
114
+ }
115
+ /* ------------------------------------------------------------------ *
116
+ * Component
117
+ * ------------------------------------------------------------------ */
118
+ function rasterImage(raster, sampling) {
119
+ const { docRect } = raster;
120
+ return (_jsx(Image
121
+ // `raster.key` is the cache key: globally unique and stable for as long as
122
+ // the bytes live. Using it verbatim (rather than prefixing by role) means a
123
+ // raster demoted from `detail` to `retiring` keeps its Skia node instead of
124
+ // being destroyed and recreated on the swap commit.
125
+ , { image: raster.image,
126
+ // The raster's docRect IS the rect the bytes were rendered for — never
127
+ // inset. See the PdfRaster invariant.
128
+ x: docRect.x, y: docRect.y, width: docRect.width, height: docRect.height,
129
+ // fit="fill" ON EVERY IMAGE, ALWAYS. RN Skia's <Image> defaults to
130
+ // "contain", which letterboxes whenever the bitmap's aspect ratio differs
131
+ // from the destination rect by even a rounding error — silently shifting
132
+ // the page a fraction of a point and misregistering it against every
133
+ // annotation layer drawn above it. "fill" makes the destination rect
134
+ // authoritative, which is the whole premise of doc-space rects.
135
+ fit: "fill", sampling: sampling }, raster.key));
136
+ }
137
+ export function PdfContentView({ content, zIndex, sampling = 'linear', paperColor = null, opacity, blendMode, children, }) {
138
+ const samplingOptions = SAMPLING[sampling] ?? SAMPLING_LINEAR;
139
+ useEffect(() => {
140
+ if (!IS_DEV) {
141
+ return;
142
+ }
143
+ Children.forEach(children, child => {
144
+ if (isValidElement(child) && isPaintNode(child.type)) {
145
+ console.warn('[react-native-pdf-canvas] PdfContentView received a <Paint> child. A Paint ' +
146
+ 'child draws each node twice (once at the declared paint, once at the ' +
147
+ 'inherited one), and paintDeclarations.clear() runs on the first drawing — ' +
148
+ 'so it would apply to the base raster and to none of the detail rasters. ' +
149
+ 'Pass a ColorFilter / ImageFilter / Shader / PathEffect declaration instead, ' +
150
+ 'or wrap PdfContentView in your own <Group>.');
151
+ }
152
+ });
153
+ }, [children]);
154
+ // A single flat array so React keys are not namespaced by an outer array index —
155
+ // otherwise reordering pages (a layout change) would remount every image node
156
+ // even though the rasters themselves are unchanged.
157
+ const nodes = useMemo(() => {
158
+ const out = [];
159
+ if (paperColor != null) {
160
+ for (const page of content.pages) {
161
+ const { pageRect } = page;
162
+ out.push(_jsx(Rect, { x: pageRect.x, y: pageRect.y, width: pageRect.width, height: pageRect.height, color: paperColor }, `paper:${page.page}`));
163
+ }
164
+ }
165
+ for (const page of content.pages) {
166
+ // BASE first: the whole page at fit scale, in one or more pieces whose union
167
+ // is the page rect. It is never emptied once populated, and it is the entire
168
+ // anti-blank mechanism — everything above it is an optional sharpening pass
169
+ // over a page that is already fully painted. Within the set the order does
170
+ // not matter: the pieces overlap by a couple of device pixels and carry the
171
+ // same bytes there.
172
+ for (const raster of page.base) {
173
+ out.push(rasterImage(raster, samplingOptions));
174
+ }
175
+ // RETIRING before DETAIL. The previous detail set has to draw UNDER its
176
+ // replacement: it exists only to cover the region the new set has not
177
+ // finished painting, and drawing it over would show stale, wrongly-scaled
178
+ // pixels on top of correct ones.
179
+ for (const raster of page.retiring) {
180
+ out.push(rasterImage(raster, samplingOptions));
181
+ }
182
+ for (const raster of page.detail) {
183
+ out.push(rasterImage(raster, samplingOptions));
184
+ }
185
+ }
186
+ return out;
187
+ }, [content, paperColor, samplingOptions]);
188
+ return (_jsxs(Group, { zIndex: zIndex, opacity: opacity, blendMode: blendMode, children: [children, nodes] }));
189
+ }
@@ -0,0 +1,6 @@
1
+ export { PdfContentView } from './PdfContentView.js';
2
+ export type { PdfContentViewProps, PdfBlendMode } from './PdfContentView.js';
3
+ export { usePdfDocument, openPdfDocument, makeSkImageFromPixels, setDefaultRasterizer, getDefaultRasterizer, } from './usePdfDocument.js';
4
+ export type { PdfDocument, PdfDocumentStatus, PdfPageRender, PdfRenderOptions, UsePdfDocumentOptions, UsePdfDocumentResult, } from './usePdfDocument.js';
5
+ export { usePdfLayer, DEFAULT_PAGE_SELECTION } from './usePdfLayer.js';
6
+ export type { PdfLayer, PdfOnScreenPages, PdfPageSelection, PdfPageWindow, UsePdfLayerOptions, } from './usePdfLayer.js';
@@ -0,0 +1,3 @@
1
+ export { PdfContentView } from './PdfContentView.js';
2
+ export { usePdfDocument, openPdfDocument, makeSkImageFromPixels, setDefaultRasterizer, getDefaultRasterizer, } from './usePdfDocument.js';
3
+ export { usePdfLayer, DEFAULT_PAGE_SELECTION } from './usePdfLayer.js';
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Opening a PDF, and rendering one-off page rasters from it.
3
+ *
4
+ * The hook mirrors RN Skia's `useImage` contract — null until ready, never
5
+ * throws, mount-guarded, keyed on source identity — with ONE deliberate
6
+ * difference: it CLOSES the document on unmount. `useImage` can get away with
7
+ * leaving cleanup to the JSI finaliser because an SkImage is only bytes; a PDF
8
+ * handle is a file descriptor (PDFium's `FPDF_LoadDocument`, Android's
9
+ * `ParcelFileDescriptor`, PDFKit's mapped file). Leaking those is how a document
10
+ * browser dies at the fd limit after a few dozen opens.
11
+ *
12
+ * `renderPage` is what makes this package generically useful rather than shaped
13
+ * around one screen: thumbnail strips, a print preview, a static first-page hero
14
+ * all want a single raster at a chosen size and none of them want an epoch, a
15
+ * cadence policy, or a controller.
16
+ */
17
+ import type { SkImage } from '@shopify/react-native-skia';
18
+ import { PdfError } from '../types.js';
19
+ import type { DocRect, DocSize, PageGeometry, PageRasterizer, PdfSource, PixelSize, RasterPixels, RasterizerCapabilities, RasterizerHandle } from '../types.js';
20
+ /** Install the rasterizer used when `usePdfDocument` / `openPdfDocument` are not
21
+ * given one explicitly. Returns the previous value so a test can restore it. */
22
+ export declare function setDefaultRasterizer(rasterizer: PageRasterizer | null): PageRasterizer | null;
23
+ export declare function getDefaultRasterizer(): PageRasterizer | null;
24
+ /** A single raster produced by `PdfDocument.renderPage`. */
25
+ export interface PdfPageRender {
26
+ readonly page: number;
27
+ readonly image: SkImage;
28
+ /** Exactly the doc-space rect the bytes were rendered for. Never inset. */
29
+ readonly docRect: DocRect;
30
+ /** Device pixels per PDF point. */
31
+ readonly scale: number;
32
+ readonly pixels: PixelSize;
33
+ readonly bytes: number;
34
+ /**
35
+ * Drop the GPU/CPU image. EXPLICIT because the caller owns this raster — it is
36
+ * not in the layer cache and nothing else will ever free it.
37
+ */
38
+ release(): void;
39
+ }
40
+ export interface PdfRenderOptions {
41
+ /** Doc-space rect to render. Defaults to the whole page at the origin. */
42
+ docRect?: DocRect;
43
+ /** Device pixels per PDF point. Ignored when `widthPx` is given. */
44
+ scale?: number;
45
+ /** Target width in device pixels; derives `scale` from `docRect.width`. */
46
+ widthPx?: number;
47
+ /**
48
+ * Draw PDF-embedded annotations into the raster.
49
+ *
50
+ * Defaults to TRUE here — a file raster (thumbnail, print, hero) is expected to
51
+ * look like the document does in any other viewer. The layer path passes false,
52
+ * because the Skia scene draws the app's own annotations and they would
53
+ * double-draw. Silently downgraded to false if the backend cannot do it.
54
+ */
55
+ annotations?: boolean;
56
+ /**
57
+ * Not in the assignment's option list, but `RasterRequest.background` is
58
+ * required and defaulting it invisibly is exactly the "opaque white under a
59
+ * transparent PDF" bug that `paperColor` defaults to null to avoid. White is
60
+ * the right default for a file raster; transparent is available for callers
61
+ * compositing onto their own surface.
62
+ */
63
+ background?: 'white' | 'transparent';
64
+ signal?: AbortSignal;
65
+ }
66
+ export interface PdfDocument {
67
+ readonly pageCount: number;
68
+ /** Identifier of the backend that produced this document, for diagnostics. */
69
+ readonly rasterizerId: string;
70
+ readonly capabilities: RasterizerCapabilities;
71
+ /** Cheap: does not load a page. */
72
+ pageGeometry(index: number): PageGeometry;
73
+ /** Intrinsic sizes of every page, in the order a `PdfPageLayout` expects. */
74
+ pageSizes(): DocSize[];
75
+ renderPage(index: number, options?: PdfRenderOptions): Promise<PdfPageRender>;
76
+ /** True once `close()` has run. Every further call rejects with `cancelled`. */
77
+ readonly closed: boolean;
78
+ /**
79
+ * Release the underlying file handle. `usePdfDocument` owns this for documents
80
+ * it opened — do not call it on a hook-owned document. Public because
81
+ * `openPdfDocument` is usable outside React.
82
+ */
83
+ close(): void;
84
+ /**
85
+ * The open backend handle. Passed to `createPdfController`; not otherwise part
86
+ * of the supported surface.
87
+ */
88
+ readonly handle: RasterizerHandle;
89
+ }
90
+ /**
91
+ * ONE conversion for the whole package, delegated to the ingest seam.
92
+ *
93
+ * This used to be a second, simpler implementation of `imageFromPixels`, and the
94
+ * two disagreed on the things that are easy to get wrong and impossible to debug
95
+ * from a screenshot:
96
+ *
97
+ * - `RasterPixels.format` travels with the bytes rather than being assumed,
98
+ * because the backends genuinely differ (PDFium is BGRA unless asked
99
+ * otherwise, Android `ARGB_8888` is RGBA in memory, CGBitmapContext is
100
+ * whatever it was configured as). Getting it wrong swaps red and blue.
101
+ * - `Skia.Data.fromBytes` IGNORES byteOffset/byteLength on native
102
+ * (JsiSkDataFactory.h copies the whole ArrayBuffer), so a `subarray()` view
103
+ * into a shared scratch buffer ingests shifted bytes and shears. Ingest
104
+ * copies to a tight buffer; this function did not.
105
+ * - A `MakeImage` that returns null for BGRA is retried once as RGBA there,
106
+ * rather than losing the page.
107
+ *
108
+ * Kept as an export because it is the documented name for "pixels to SkImage"
109
+ * on the document surface, and `renderPage` below is its main caller.
110
+ */
111
+ export declare function makeSkImageFromPixels(pixels: RasterPixels): SkImage;
112
+ /**
113
+ * Open a document outside React. The caller owns `close()`.
114
+ */
115
+ export declare function openPdfDocument(source: PdfSource, rasterizer?: PageRasterizer): Promise<PdfDocument>;
116
+ export type PdfDocumentStatus = 'idle' | 'loading' | 'ready' | 'error';
117
+ export interface UsePdfDocumentResult {
118
+ document: PdfDocument | null;
119
+ status: PdfDocumentStatus;
120
+ error: PdfError | null;
121
+ }
122
+ export interface UsePdfDocumentOptions {
123
+ rasterizer?: PageRasterizer;
124
+ }
125
+ /**
126
+ * Open `source` and keep it open for the life of the component.
127
+ *
128
+ * KEYED ON SOURCE IDENTITY, exactly like `useImage`: the effect re-runs when the
129
+ * `source` REFERENCE changes, so an inline `{uri}` object literal reopens the file
130
+ * on every render. Memoise it, or hoist it to a module constant.
131
+ *
132
+ * Never throws. A failure lands in `error` with a `PdfErrorCode` on it.
133
+ */
134
+ export declare function usePdfDocument(source: PdfSource | null | undefined, options?: UsePdfDocumentOptions): UsePdfDocumentResult;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Opening a PDF, and rendering one-off page rasters from it.
3
+ *
4
+ * The hook mirrors RN Skia's `useImage` contract — null until ready, never
5
+ * throws, mount-guarded, keyed on source identity — with ONE deliberate
6
+ * difference: it CLOSES the document on unmount. `useImage` can get away with
7
+ * leaving cleanup to the JSI finaliser because an SkImage is only bytes; a PDF
8
+ * handle is a file descriptor (PDFium's `FPDF_LoadDocument`, Android's
9
+ * `ParcelFileDescriptor`, PDFKit's mapped file). Leaking those is how a document
10
+ * browser dies at the fd limit after a few dozen opens.
11
+ *
12
+ * `renderPage` is what makes this package generically useful rather than shaped
13
+ * around one screen: thumbnail strips, a print preview, a static first-page hero
14
+ * all want a single raster at a chosen size and none of them want an epoch, a
15
+ * cadence policy, or a controller.
16
+ */
17
+ import { useEffect, useState } from 'react';
18
+ // Through the `./skia` barrel — see the note in `usePdfLayer.ts`.
19
+ import { imageFromPixels } from '../skia/index.js';
20
+ import { PdfError } from '../types.js';
21
+ /* ------------------------------------------------------------------ *
22
+ * Default rasterizer registry
23
+ * ------------------------------------------------------------------ *
24
+ *
25
+ * CONTRACT GAP, flagged rather than papered over: `PageRasterizer` is documented
26
+ * as "injected per document, defaulting to the platform module", but the package
27
+ * entry points are platform-agnostic one-line re-exports (see index.native.ts),
28
+ * so there is no entry-point seam to hang a platform default on, and importing a
29
+ * guessed module path from here would couple this file to a file it does not own.
30
+ *
31
+ * Instead the default is a settable module-level slot. The platform module (or
32
+ * the app, or a test) installs it once; `usePdfDocument` falls back to it when no
33
+ * rasterizer is passed. If a platform module later wants to register itself on
34
+ * import, it calls `setDefaultRasterizer` at module scope and nothing here
35
+ * changes.
36
+ */
37
+ let defaultRasterizer = null;
38
+ /** Install the rasterizer used when `usePdfDocument` / `openPdfDocument` are not
39
+ * given one explicitly. Returns the previous value so a test can restore it. */
40
+ export function setDefaultRasterizer(rasterizer) {
41
+ const previous = defaultRasterizer;
42
+ defaultRasterizer = rasterizer;
43
+ return previous;
44
+ }
45
+ export function getDefaultRasterizer() {
46
+ return defaultRasterizer;
47
+ }
48
+ /* ------------------------------------------------------------------ *
49
+ * Pixels -> SkImage
50
+ * ------------------------------------------------------------------ */
51
+ /**
52
+ * ONE conversion for the whole package, delegated to the ingest seam.
53
+ *
54
+ * This used to be a second, simpler implementation of `imageFromPixels`, and the
55
+ * two disagreed on the things that are easy to get wrong and impossible to debug
56
+ * from a screenshot:
57
+ *
58
+ * - `RasterPixels.format` travels with the bytes rather than being assumed,
59
+ * because the backends genuinely differ (PDFium is BGRA unless asked
60
+ * otherwise, Android `ARGB_8888` is RGBA in memory, CGBitmapContext is
61
+ * whatever it was configured as). Getting it wrong swaps red and blue.
62
+ * - `Skia.Data.fromBytes` IGNORES byteOffset/byteLength on native
63
+ * (JsiSkDataFactory.h copies the whole ArrayBuffer), so a `subarray()` view
64
+ * into a shared scratch buffer ingests shifted bytes and shears. Ingest
65
+ * copies to a tight buffer; this function did not.
66
+ * - A `MakeImage` that returns null for BGRA is retried once as RGBA there,
67
+ * rather than losing the page.
68
+ *
69
+ * Kept as an export because it is the documented name for "pixels to SkImage"
70
+ * on the document surface, and `renderPage` below is its main caller.
71
+ */
72
+ export function makeSkImageFromPixels(pixels) {
73
+ return imageFromPixels(pixels);
74
+ }
75
+ /* ------------------------------------------------------------------ *
76
+ * openPdfDocument
77
+ * ------------------------------------------------------------------ */
78
+ /**
79
+ * Read the abort flag THROUGH A CALL, on both sides of the render.
80
+ *
81
+ * `if (signal.aborted === true)` narrows `signal.aborted` to `false | undefined`
82
+ * for the rest of the function, and TypeScript keeps that narrowing across an
83
+ * `await` — it has no way to know that an AbortController on the other side of
84
+ * the boundary can flip the flag while the render is in flight. The post-render
85
+ * check would therefore be reported as dead code (TS2367) while being the one
86
+ * that actually matters: it is what stops a raster landing for a viewport, or a
87
+ * component, that is already gone. A function call produces no narrowing, so both
88
+ * checks stay honest.
89
+ */
90
+ function isAborted(signal) {
91
+ return signal?.aborted === true;
92
+ }
93
+ function toPdfError(cause, fallback) {
94
+ if (cause instanceof PdfError) {
95
+ return cause;
96
+ }
97
+ const message = cause instanceof Error ? cause.message : String(cause);
98
+ return new PdfError('backend-failure', `${fallback}: ${message}`, { cause });
99
+ }
100
+ /**
101
+ * Open a document outside React. The caller owns `close()`.
102
+ */
103
+ export async function openPdfDocument(source, rasterizer) {
104
+ const backend = rasterizer ?? defaultRasterizer;
105
+ if (backend === null || backend === undefined) {
106
+ throw new PdfError('unsupported', 'No PageRasterizer available. Pass one explicitly, or install a platform default with setDefaultRasterizer().');
107
+ }
108
+ let handle;
109
+ try {
110
+ handle = await backend.open(source);
111
+ }
112
+ catch (cause) {
113
+ throw toPdfError(cause, 'Failed to open PDF');
114
+ }
115
+ let closed = false;
116
+ const doc = {
117
+ handle,
118
+ pageCount: handle.pageCount,
119
+ rasterizerId: backend.id,
120
+ capabilities: backend.capabilities,
121
+ get closed() {
122
+ return closed;
123
+ },
124
+ pageGeometry(index) {
125
+ return handle.pageGeometry(index);
126
+ },
127
+ pageSizes() {
128
+ const sizes = [];
129
+ for (let i = 0; i < handle.pageCount; i++) {
130
+ const geometry = handle.pageGeometry(i);
131
+ sizes.push({ width: geometry.width, height: geometry.height });
132
+ }
133
+ return sizes;
134
+ },
135
+ async renderPage(index, options) {
136
+ if (closed) {
137
+ throw new PdfError('cancelled', 'renderPage called on a closed PdfDocument');
138
+ }
139
+ if (!Number.isInteger(index) || index < 0 || index >= handle.pageCount) {
140
+ // No 'invalid-argument' code exists in PdfErrorCode; 'backend-failure' is
141
+ // the closest honest bucket. Flagged in the report.
142
+ throw new PdfError('backend-failure', `page index ${index} is out of range (pageCount ${handle.pageCount})`);
143
+ }
144
+ if (isAborted(options?.signal)) {
145
+ throw new PdfError('cancelled', 'renderPage aborted before it started');
146
+ }
147
+ const geometry = handle.pageGeometry(index);
148
+ const docRect = options?.docRect ?? {
149
+ x: 0,
150
+ y: 0,
151
+ width: geometry.width,
152
+ height: geometry.height,
153
+ };
154
+ if (!(docRect.width > 0) || !(docRect.height > 0)) {
155
+ throw new PdfError('backend-failure', `renderPage needs a positive docRect, got ${docRect.width}x${docRect.height}`);
156
+ }
157
+ const widthPx = options?.widthPx;
158
+ const scale = widthPx !== undefined && widthPx > 0
159
+ ? widthPx / docRect.width
160
+ : (options?.scale ?? 1);
161
+ if (!(scale > 0) || !Number.isFinite(scale)) {
162
+ throw new PdfError('backend-failure', `renderPage needs a positive scale, got ${scale}`);
163
+ }
164
+ let pixels;
165
+ try {
166
+ pixels = await handle.render({
167
+ page: index,
168
+ docRect,
169
+ scale,
170
+ // A backend that cannot draw annotations would otherwise silently
171
+ // ignore the flag; downgrading here keeps the request honest.
172
+ annotations: (options?.annotations ?? true) &&
173
+ backend.capabilities.annotations,
174
+ background: options?.background ?? 'white',
175
+ }, options?.signal);
176
+ }
177
+ catch (cause) {
178
+ throw toPdfError(cause, `Failed to render page ${index}`);
179
+ }
180
+ // The handle may have been closed while the render was in flight; the image
181
+ // itself is fine (the bytes are already copied out) but the caller almost
182
+ // certainly no longer wants it.
183
+ if (isAborted(options?.signal)) {
184
+ throw new PdfError('cancelled', 'renderPage aborted');
185
+ }
186
+ const image = makeSkImageFromPixels(pixels);
187
+ let released = false;
188
+ return {
189
+ page: index,
190
+ image,
191
+ docRect,
192
+ scale,
193
+ pixels: { width: pixels.width, height: pixels.height },
194
+ bytes: pixels.rowBytes * pixels.height,
195
+ release() {
196
+ if (released) {
197
+ return;
198
+ }
199
+ released = true;
200
+ image.dispose();
201
+ },
202
+ };
203
+ },
204
+ close() {
205
+ if (closed) {
206
+ return;
207
+ }
208
+ closed = true;
209
+ handle.close();
210
+ },
211
+ };
212
+ return doc;
213
+ }
214
+ const IDLE = Object.freeze({
215
+ document: null,
216
+ status: 'idle',
217
+ error: null,
218
+ });
219
+ const LOADING = Object.freeze({
220
+ document: null,
221
+ status: 'loading',
222
+ error: null,
223
+ });
224
+ /**
225
+ * Open `source` and keep it open for the life of the component.
226
+ *
227
+ * KEYED ON SOURCE IDENTITY, exactly like `useImage`: the effect re-runs when the
228
+ * `source` REFERENCE changes, so an inline `{uri}` object literal reopens the file
229
+ * on every render. Memoise it, or hoist it to a module constant.
230
+ *
231
+ * Never throws. A failure lands in `error` with a `PdfErrorCode` on it.
232
+ */
233
+ export function usePdfDocument(source, options) {
234
+ const rasterizer = options?.rasterizer;
235
+ const [state, setState] = useState(IDLE);
236
+ useEffect(() => {
237
+ if (source === null || source === undefined) {
238
+ setState(IDLE);
239
+ return;
240
+ }
241
+ // Mount guard: `cancelled` covers both unmount and a source change landing
242
+ // before the open resolves. Without it a slow open on a discarded source
243
+ // overwrites the state of the current one.
244
+ let cancelled = false;
245
+ let opened = null;
246
+ setState(LOADING);
247
+ openPdfDocument(source, rasterizer).then(document => {
248
+ if (cancelled) {
249
+ // We own this handle and nobody will ever see it — close it here or the
250
+ // fd leaks for the lifetime of the process.
251
+ document.close();
252
+ return;
253
+ }
254
+ opened = document;
255
+ setState({ document, status: 'ready', error: null });
256
+ }, (cause) => {
257
+ if (cancelled) {
258
+ return;
259
+ }
260
+ setState({
261
+ document: null,
262
+ status: 'error',
263
+ error: toPdfError(cause, 'Failed to open PDF'),
264
+ });
265
+ });
266
+ return () => {
267
+ cancelled = true;
268
+ // THE DIFFERENCE FROM useImage: a PDF handle is a file descriptor, not just
269
+ // bytes, so unmount has to close it rather than leaving it to the finaliser.
270
+ opened?.close();
271
+ opened = null;
272
+ };
273
+ }, [source, rasterizer]);
274
+ return state;
275
+ }