@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 +136 -0
- package/assets/README.md +29 -0
- package/assets/bridge.bundle +253 -0
- package/assets/bridge.html +18 -0
- package/assets/image2pdf.bundle +992 -0
- package/assets/image2pdf.d.ts +147 -0
- package/assets/image2pdf_bg.wasm +0 -0
- package/assets/image2pdf_bg.wasm.d.ts +24 -0
- package/package.json +45 -0
- package/src/Bridge.tsx +169 -0
- package/src/assets.d.ts +9 -0
- package/src/debugLog.ts +12 -0
- package/src/index.ts +20 -0
- package/src/loadBridgeBundle.ts +79 -0
- package/src/protocol.ts +43 -0
- package/src/readUriBase64.ts +39 -0
- package/src/types.ts +33 -0
- package/src/useImage2Pdf.tsx +296 -0
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).
|
package/assets/README.md
ADDED
|
@@ -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>
|