@skyprint/image2pdf-expo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @skyprint/image2pdf-expo
2
+
3
+ React Native / Expo bridge for the
4
+ [`image2pdf`](https://git.duguying.net/cloudprinter/image2pdf) WebAssembly
5
+ library. Converts JPG / PNG / (pre-converted) HEIC images into a single
6
+ A4 PDF entirely on-device, with no network calls and no cloud round-trip.
7
+
8
+ This package exists because **Hermes** (the JS engine bundled in Expo
9
+ Go) does not implement `WebAssembly`. The wasm is therefore loaded
10
+ inside a hidden `react-native-webview`, which runs the full browser
11
+ kernel (iOS WebKit / Android Blink) and *does* support wasm. We talk to
12
+ the WebView via `postMessage` and treat the wasm as a black box.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ # in your Expo / RN app
18
+ npm install @skyprint/image2pdf-expo
19
+ # + the peer dependencies (see package.json):
20
+ # expo, expo-asset, expo-file-system, react-native-webview
21
+ ```
22
+
23
+ ## Use
24
+
25
+ ```tsx
26
+ import { useImage2Pdf, Image2PdfBridge } from '@skyprint/image2pdf-expo';
27
+
28
+ function MyScreen() {
29
+ const { convert, status, result, error } = useImage2Pdf();
30
+
31
+ return (
32
+ <>
33
+ {/* Mount the bridge once near the top of your tree.
34
+ It renders as a 0×0 absolutely-positioned WebView. */}
35
+ <Image2PdfBridge />
36
+
37
+ <Button
38
+ title="Convert"
39
+ onPress={() => convert(imageUris, { title: 'my-doc', marginMm: 5 })}
40
+ />
41
+
42
+ <Text>status: {status}</Text>
43
+ {result && <Text>PDF: {result.pages} pages, {result.bytes} bytes, {result.durationMs} ms</Text>}
44
+ {error && <Text>error: {String(error)}</Text>}
45
+ </>
46
+ );
47
+ }
48
+ ```
49
+
50
+ `convert()` takes a list of `file://` URIs (the same shape
51
+ `expo-image-picker` returns) and returns:
52
+
53
+ ```ts
54
+ interface ConvertResult {
55
+ uri: string; // local file:// URI of the saved PDF (in cache dir)
56
+ bytes: number; // PDF size in bytes
57
+ pages: number; // page count
58
+ durationMs: number; // wall time spent in the wasm convert() call
59
+ }
60
+ ```
61
+
62
+ ## How the bridge works
63
+
64
+ ```
65
+ ┌──────────────────────┐ ┌─────────────────────────────┐
66
+ │ React Native side │ post │ Hidden WebView │
67
+ │ (useImage2Pdf hook) │ ─────► │ bridge.js glue │
68
+ │ │ ◄───── │ └─ image2pdf.js (wasm) │
69
+ └──────────────────────┘ │ └─ image2pdf_bg.wasm │
70
+ └─────────────────────────────┘
71
+ ```
72
+
73
+ For 30 × 8 MB HEIC photos, the wasm call itself takes < 1 s; the
74
+ WebView's data-URL fetch of the local files is essentially a memcpy.
75
+
76
+ ## Options
77
+
78
+ | Field | Default | Description |
79
+ | ---------- | -------------- | ------------------------------------------ |
80
+ | `title` | `"image2pdf"` | PDF document title |
81
+ | `author` | `""` | PDF author metadata |
82
+ | `marginMm` | `10` | Page margin in millimeters |
83
+ | `dpi` | `75` | Pixels-per-inch for image → point scaling |
84
+
85
+ ## HEIC support
86
+
87
+ iOS camera photos are HEIC by default. Our wasm does **not** decode HEIC
88
+ on React Native (it would require C++ stdlib in the bundle). The
89
+ recommended workaround:
90
+
91
+ ```tsx
92
+ import * as ImageManipulator from 'expo-image-manipulator';
93
+
94
+ const jpegUri = await ImageManipulator.manipulateAsync(
95
+ heicUri,
96
+ [],
97
+ { compress: 1, format: ImageManipulator.SaveFormat.JPEG },
98
+ ).then((r) => r.uri);
99
+ ```
100
+
101
+ `expo-image-manipulator` uses iOS ImageIO / Android MediaCodec, so the
102
+ HEIC decode never enters our wasm. Functionally equivalent to native
103
+ HEIC support, with a small CPU cost (the re-encode is fast on modern
104
+ phones and the resulting JPEG is smaller than HEIC anyway).
105
+
106
+ ## Example app (SDK 55)
107
+
108
+ ```sh
109
+ # from repo root — stage wasm into assets/
110
+ make expo-build
111
+
112
+ cd example
113
+ npm install
114
+ npx expo start --clear
115
+ ```
116
+
117
+ The example depends on this package via `"file:.."`. Metro needs
118
+ [`example/metro.config.js`](example/metro.config.js) to watch the parent
119
+ directory. **Do not run `npm install` in `integrations/expo/`** (the
120
+ library root) — that creates a second `node_modules` with its own
121
+ `react-native` and breaks the example bundler.
122
+
123
+ ## Build / publish
124
+
125
+ ```sh
126
+ # from the image2pdf repo root, after editing the rust lib:
127
+ make expo-build # rebuilds wasm, copies into this package's assets/
128
+
129
+ # then in this directory:
130
+ npm version patch
131
+ npm publish
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT — see the parent repo's [LICENSE](../../LICENSE).
@@ -0,0 +1,29 @@
1
+ # image2pdf
2
+
3
+ Convert JPG, PNG, and HEIC images to a single A4 PDF — entirely in WebAssembly,
4
+ running in the browser, Node.js, a bundler, or **React Native (Expo Go) via a
5
+ WebView bridge**.
6
+
7
+ Status: 🚧 early development. See [`/root/.claude/plans/webassembly-rust-jpg-png-heic-pdf-witty-wreath.md`](/root/.claude/plans/webassembly-rust-jpg-png-heic-pdf-witty-wreath.md) for the implementation plan.
8
+
9
+ ## Build
10
+
11
+ ```sh
12
+ # install prerequisites once (Linux example)
13
+ rustup target add wasm32-unknown-unknown
14
+ curl https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
15
+
16
+ # build the three wasm-pack targets
17
+ ./scripts/build-wasm.sh
18
+ ```
19
+
20
+ ## Use
21
+
22
+ Detailed usage docs land alongside Step 6 (Expo integration). For now see the
23
+ plan file for the API surface and the architecture diagram.
24
+
25
+ ## License
26
+
27
+ MIT. See [LICENSE](./LICENSE). Third-party dependencies are listed in
28
+ [THIRD_PARTY_LICENSES.md](../../THIRD_PARTY_LICENSES.md) (includes the optional
29
+ `heic` crate — AGPL-3.0-only or Imazen commercial).
@@ -0,0 +1,253 @@
1
+ // WebView-side glue for image2pdf.
2
+ //
3
+ // Shipped as `bridge.bundle` (Metro asset) and staged as `bridge.js` beside
4
+ // bridge.html so ES module relative imports resolve. Extension `.bundle` avoids
5
+ // Metro treating `*.web.js` as a `web` platform source file (ext `js`).
6
+
7
+ const statusEl = document.getElementById('status');
8
+ const logEl = document.getElementById('log');
9
+
10
+ const RN_BRIDGE = window.ReactNativeWebView;
11
+ const post = (msg) => RN_BRIDGE && RN_BRIDGE.postMessage(JSON.stringify(msg));
12
+
13
+ function log(msg, data) {
14
+ const line = data !== undefined ? `${msg} ${JSON.stringify(data)}` : msg;
15
+ const stamped = `${new Date().toISOString()} ${line}`;
16
+ if (logEl) logEl.textContent += stamped + '\n';
17
+ if (typeof console !== 'undefined') console.log('[image2pdf:bridge]', stamped);
18
+ try {
19
+ post({ type: 'log', message: line });
20
+ } catch {
21
+ /* post() not ready yet */
22
+ }
23
+ }
24
+
25
+ /** @type {((images: Uint8Array[], options: object) => Uint8Array) | null} */
26
+ let convertFn = null;
27
+
28
+ /** id → Uint8Array. Kept in-memory until `finalize` (or `reset`) is called. */
29
+ const images = new Map();
30
+
31
+ /** id → in-flight chunked base64 from RN */
32
+ const pendingAdds = new Map();
33
+
34
+ /** Idempotent, sequential, single-shot. */
35
+ let busy = false;
36
+
37
+ /** @type {{ onChunk: (msg: object) => void; onErr: (msg: object) => void } | null} */
38
+ let wasmWaiter = null;
39
+
40
+ function base64ToArrayBuffer(b64) {
41
+ const binary = atob(b64);
42
+ const bytes = new Uint8Array(binary.length);
43
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
44
+ return bytes.buffer;
45
+ }
46
+
47
+ function base64ToUint8Array(b64) {
48
+ const binary = atob(b64);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
51
+ return bytes;
52
+ }
53
+
54
+ function requestWasmBytes() {
55
+ return new Promise((resolve, reject) => {
56
+ const chunks = [];
57
+ let total = null;
58
+ const timeout = setTimeout(() => {
59
+ wasmWaiter = null;
60
+ reject(new Error('wasm bytes timeout'));
61
+ }, 120_000);
62
+
63
+ wasmWaiter = {
64
+ onChunk(msg) {
65
+ if (total === null) total = msg.total;
66
+ chunks[msg.index] = msg.data;
67
+ if (chunks.length === total && chunks.every((c) => c != null)) {
68
+ clearTimeout(timeout);
69
+ wasmWaiter = null;
70
+ try {
71
+ resolve(base64ToArrayBuffer(chunks.join('')));
72
+ } catch (e) {
73
+ reject(e);
74
+ }
75
+ }
76
+ },
77
+ onErr(msg) {
78
+ clearTimeout(timeout);
79
+ wasmWaiter = null;
80
+ reject(new Error(msg.message || 'wasm delivery failed'));
81
+ },
82
+ };
83
+ post({ type: 'needWasm' });
84
+ });
85
+ }
86
+
87
+ (async () => {
88
+ try {
89
+ log('wasm init start');
90
+ const mod = await import('./image2pdf.js');
91
+ log('requesting wasm from RN');
92
+ const wasmBytes = await requestWasmBytes();
93
+ log('wasm bytes loaded', { bytes: wasmBytes.byteLength });
94
+ await mod.default(wasmBytes);
95
+ convertFn = mod.convert;
96
+ statusEl.textContent = 'ready';
97
+ log('wasm init ok');
98
+ post({ type: 'ready' });
99
+ } catch (e) {
100
+ const message = e?.message || String(e);
101
+ statusEl.textContent = 'wasm load failed: ' + message;
102
+ log('wasm init failed', { message, stack: e?.stack || String(e) });
103
+ post({ type: 'error', requestId: '__wasm__', message: 'wasm init failed: ' + message });
104
+ }
105
+ })();
106
+
107
+ function onRnMessage(ev) {
108
+ let msg;
109
+ try {
110
+ msg = JSON.parse(ev.data);
111
+ } catch {
112
+ log('ignoring non-JSON message: ' + ev.data);
113
+ return;
114
+ }
115
+ if (wasmWaiter) {
116
+ if (msg.type === 'wasmChunk') {
117
+ wasmWaiter.onChunk(msg);
118
+ return;
119
+ }
120
+ if (msg.type === 'wasmErr') {
121
+ wasmWaiter.onErr(msg);
122
+ return;
123
+ }
124
+ }
125
+ void handle(msg);
126
+ }
127
+
128
+ window.addEventListener('message', onRnMessage);
129
+ document.addEventListener('message', onRnMessage);
130
+
131
+ async function handle(msg) {
132
+ log('handle', { type: msg.type, images: images.size, busy });
133
+ switch (msg.type) {
134
+ case 'init':
135
+ break;
136
+
137
+ case 'addBytes': {
138
+ if (busy) {
139
+ post({ type: 'addErr', id: msg.id, message: 'busy' });
140
+ return;
141
+ }
142
+ let pending = pendingAdds.get(msg.id);
143
+ if (!pending) {
144
+ pending = { chunks: [], total: msg.total };
145
+ pendingAdds.set(msg.id, pending);
146
+ log('addBytes start', { id: msg.id, total: msg.total });
147
+ }
148
+ pending.chunks[msg.index] = msg.data;
149
+ if (pending.chunks.length !== msg.total || !pending.chunks.every((c) => c != null)) {
150
+ return;
151
+ }
152
+ pendingAdds.delete(msg.id);
153
+ try {
154
+ const bytes = base64ToUint8Array(pending.chunks.join(''));
155
+ images.set(msg.id, bytes);
156
+ log('add ok', { id: msg.id, bytes: bytes.length, total: images.size });
157
+ post({ type: 'addOk', id: msg.id });
158
+ } catch (e) {
159
+ log('add decode failed', { id: msg.id, message: String(e.message || e) });
160
+ post({ type: 'addErr', id: msg.id, message: String(e.message || e) });
161
+ }
162
+ break;
163
+ }
164
+
165
+ case 'finalize': {
166
+ if (busy) {
167
+ post({ type: 'error', requestId: msg.requestId, message: 'busy' });
168
+ return;
169
+ }
170
+ if (!convertFn) {
171
+ post({
172
+ type: 'error',
173
+ requestId: msg.requestId,
174
+ message: 'wasm not initialized',
175
+ });
176
+ return;
177
+ }
178
+ if (images.size === 0) {
179
+ log('finalize rejected: no images in map');
180
+ post({
181
+ type: 'error',
182
+ requestId: msg.requestId,
183
+ message: 'no images added',
184
+ });
185
+ return;
186
+ }
187
+ busy = true;
188
+ statusEl.textContent = 'converting…';
189
+ log('finalize start', { requestId: msg.requestId, images: images.size });
190
+ try {
191
+ const arr = Array.from(images.values());
192
+ const opts = {
193
+ title: msg.options?.title || 'image2pdf',
194
+ author: msg.options?.author || '',
195
+ marginMm: msg.options?.marginMm ?? 10,
196
+ dpi: msg.options?.dpi ?? 75,
197
+ };
198
+ const t0 = performance.now();
199
+ log('wasm convert() start', { pages: arr.length, opts });
200
+ const pdf = convertFn(arr, opts);
201
+ const dt = performance.now() - t0;
202
+ log('wasm convert() ok', { bytes: pdf.length, ms: dt.toFixed(1) });
203
+
204
+ const b64t0 = performance.now();
205
+ const b64 = bytesToBase64(pdf);
206
+ log('base64 encode ok', { b64Len: b64.length, ms: (performance.now() - b64t0).toFixed(1) });
207
+ log('posting done to RN');
208
+ post({
209
+ type: 'done',
210
+ requestId: msg.requestId,
211
+ pdfBase64: b64,
212
+ pages: arr.length,
213
+ bytes: pdf.length,
214
+ });
215
+ statusEl.textContent = 'done';
216
+ } catch (e) {
217
+ log('finalize error', { message: e.message || String(e), stack: e.stack });
218
+ post({
219
+ type: 'error',
220
+ requestId: msg.requestId,
221
+ message: String(e.message || e),
222
+ });
223
+ statusEl.textContent = 'error: ' + (e.message || e);
224
+ } finally {
225
+ busy = false;
226
+ }
227
+ break;
228
+ }
229
+
230
+ case 'reset': {
231
+ const n = images.size;
232
+ images.clear();
233
+ pendingAdds.clear();
234
+ log(' reset (cleared ' + n + ' images)');
235
+ break;
236
+ }
237
+
238
+ default:
239
+ log('unknown message type: ' + msg.type);
240
+ }
241
+ }
242
+
243
+ function bytesToBase64(bytes) {
244
+ let binary = '';
245
+ const chunk = 0x8000;
246
+ for (let i = 0; i < bytes.length; i += chunk) {
247
+ binary += String.fromCharCode.apply(
248
+ null,
249
+ bytes.subarray(i, Math.min(i + chunk, bytes.length)),
250
+ );
251
+ }
252
+ return btoa(binary);
253
+ }
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>image2pdf bridge</title>
6
+ <style>
7
+ body { font-family: monospace; padding: 1rem; color: #1a1a1a; }
8
+ pre { background: #f3f4f6; padding: 0.5rem; border-radius: 6px; }
9
+ #status { font-weight: bold; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1>image2pdf bridge</h1>
14
+ <div id="status">loading wasm…</div>
15
+ <pre id="log"></pre>
16
+ <script type="module" src="./bridge.js"></script>
17
+ </body>
18
+ </html>