@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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `useImage2Pdf()` — the public hook. Hides the WebView and the request/
3
+ * response protocol behind a single `convert(imageUris, options?)` call
4
+ * returning a `ConvertResult`.
5
+ *
6
+ * State machine:
7
+ * idle → ready → adding → finalizing → done → idle
8
+ * any → error → idle
9
+ *
10
+ * Callers should treat `convert()` as single-shot: if invoked while busy,
11
+ * it throws — the bridge is not designed for concurrent conversions.
12
+ */
13
+
14
+ import { useCallback, useEffect, useRef, useState } from 'react';
15
+ // SDK 55+: legacy helpers moved out of the default `expo-file-system` entry.
16
+ import * as FileSystem from 'expo-file-system/legacy';
17
+
18
+ import { Image2PdfBridge, type Image2PdfBridgeHandle } from './Bridge';
19
+ import { debugLog, debugWarn } from './debugLog';
20
+ import { B64_CHUNK_SIZE, readUriAsBase64 } from './readUriBase64';
21
+ import type { ConvertOptions, ConvertResult } from './types';
22
+ import type { WvToRn } from './protocol';
23
+
24
+ type Status = 'idle' | 'ready' | 'adding' | 'finalizing' | 'done' | 'error';
25
+
26
+ interface PendingFinalize {
27
+ resolve: (r: ConvertResult) => void;
28
+ reject: (e: Error) => void;
29
+ }
30
+
31
+ interface ConvertSession {
32
+ requestId: string;
33
+ uris: string[];
34
+ /** Index of the add we're waiting on (0 .. uris.length-1), then uris.length when all added. */
35
+ pendingIndex: number;
36
+ options?: ConvertOptions;
37
+ startedAt: number;
38
+ }
39
+
40
+ export interface UseImage2PdfReturn {
41
+ /** Convert the listed image file URIs to a PDF. */
42
+ convert(imageUris: string[], options?: ConvertOptions): Promise<ConvertResult>;
43
+ /** The bridge component to mount once. Place it near the top of your tree. */
44
+ Bridge: React.FC;
45
+ /** Current status. */
46
+ status: Status;
47
+ /** Last error, if any. Cleared on the next successful convert(). */
48
+ error: Error | null;
49
+ /** Last result, if any. */
50
+ result: ConvertResult | null;
51
+ }
52
+
53
+ let idCounter = 0;
54
+ const nextId = () => `${Date.now()}-${++idCounter}`;
55
+
56
+ export function useImage2Pdf(): UseImage2PdfReturn {
57
+ const bridgeRef = useRef<Image2PdfBridgeHandle>(null);
58
+ const pendingRef = useRef<PendingFinalize | null>(null);
59
+ const sessionRef = useRef<ConvertSession | null>(null);
60
+ const wasmReadyRef = useRef(false);
61
+ const [status, setStatus] = useState<Status>('idle');
62
+ const [error, setError] = useState<Error | null>(null);
63
+ const [result, setResult] = useState<ConvertResult | null>(null);
64
+
65
+ const failConvert = useCallback((err: Error) => {
66
+ debugWarn('convert failed:', err.message);
67
+ sessionRef.current = null;
68
+ const pending = pendingRef.current;
69
+ pendingRef.current = null;
70
+ pending?.reject(err);
71
+ setError(err);
72
+ setStatus('error');
73
+ }, []);
74
+
75
+ const postAdd = useCallback(
76
+ async (session: ConvertSession, index: number) => {
77
+ const id = `${session.requestId}-${index}`;
78
+ const uri = session.uris[index];
79
+ try {
80
+ debugLog('RN reading image', { id, uri });
81
+ const t0 = Date.now();
82
+ const b64 = await readUriAsBase64(uri);
83
+ const total = Math.ceil(b64.length / B64_CHUNK_SIZE);
84
+ for (let i = 0; i < total; i++) {
85
+ bridgeRef.current?.post({
86
+ type: 'addBytes',
87
+ id,
88
+ index: i,
89
+ total,
90
+ data: b64.slice(i * B64_CHUNK_SIZE, (i + 1) * B64_CHUNK_SIZE),
91
+ });
92
+ }
93
+ debugLog('RN→WV addBytes', { id, chunks: total, b64Len: b64.length, ms: Date.now() - t0 });
94
+ } catch (e) {
95
+ const message = e instanceof Error ? e.message : String(e);
96
+ failConvert(new Error(`failed to read image (${id}): ${message}`));
97
+ }
98
+ },
99
+ [failConvert],
100
+ );
101
+
102
+ const postFinalize = useCallback((session: ConvertSession) => {
103
+ debugLog('RN→WV finalize', {
104
+ requestId: session.requestId,
105
+ images: session.uris.length,
106
+ msSinceStart: Date.now() - session.startedAt,
107
+ });
108
+ setStatus('finalizing');
109
+ bridgeRef.current?.post({
110
+ type: 'finalize',
111
+ requestId: session.requestId,
112
+ options: session.options,
113
+ });
114
+ }, []);
115
+
116
+ // Bridges message events into a Promise + state-machine transition.
117
+ const handleMessage = useCallback(
118
+ (msg: WvToRn) => {
119
+ switch (msg.type) {
120
+ case 'log':
121
+ debugLog('[WV]', msg.message);
122
+ break;
123
+
124
+ case 'ready':
125
+ debugLog('WV→RN ready');
126
+ wasmReadyRef.current = true;
127
+ setStatus('ready');
128
+ break;
129
+
130
+ case 'addOk': {
131
+ debugLog('WV→RN addOk', msg.id);
132
+ const session = sessionRef.current;
133
+ if (!session) {
134
+ debugWarn('addOk with no active session', msg.id);
135
+ break;
136
+ }
137
+ const expectedId = `${session.requestId}-${session.pendingIndex}`;
138
+ if (msg.id !== expectedId) {
139
+ debugWarn('addOk id mismatch', { got: msg.id, expected: expectedId });
140
+ break;
141
+ }
142
+ session.pendingIndex += 1;
143
+ debugLog('addOk progress', `${session.pendingIndex}/${session.uris.length}`);
144
+ if (session.pendingIndex < session.uris.length) {
145
+ void postAdd(session, session.pendingIndex);
146
+ } else {
147
+ postFinalize(session);
148
+ }
149
+ break;
150
+ }
151
+
152
+ case 'addErr': {
153
+ debugWarn('WV→RN addErr', msg.id, msg.message);
154
+ const session = sessionRef.current;
155
+ if (session && msg.id.startsWith(session.requestId)) {
156
+ failConvert(new Error(`image add failed (${msg.id}): ${msg.message}`));
157
+ }
158
+ break;
159
+ }
160
+
161
+ case 'done': {
162
+ debugLog('WV→RN done', {
163
+ requestId: msg.requestId,
164
+ pages: msg.pages,
165
+ bytes: msg.bytes,
166
+ b64Len: msg.pdfBase64.length,
167
+ });
168
+ const pending = pendingRef.current;
169
+ if (!pending) {
170
+ debugWarn('stray done message:', msg.requestId);
171
+ return;
172
+ }
173
+ pendingRef.current = null;
174
+ sessionRef.current = null;
175
+ (async () => {
176
+ try {
177
+ const outUri = `${FileSystem.cacheDirectory}image2pdf-${msg.requestId}.pdf`;
178
+ debugLog('writing PDF to cache', outUri);
179
+ const t0 = Date.now();
180
+ await FileSystem.writeAsStringAsync(outUri, msg.pdfBase64, {
181
+ encoding: 'base64',
182
+ });
183
+ debugLog('writeAsStringAsync ok', { ms: Date.now() - t0 });
184
+ const fileInfo = await FileSystem.getInfoAsync(outUri);
185
+ debugLog('getInfoAsync', fileInfo);
186
+ const res: ConvertResult = {
187
+ uri: outUri,
188
+ bytes: fileInfo.exists ? (fileInfo.size ?? msg.bytes) : msg.bytes,
189
+ pages: msg.pages,
190
+ durationMs: 0,
191
+ };
192
+ pending.resolve(res);
193
+ setResult(res);
194
+ setStatus('done');
195
+ debugLog('convert complete', res);
196
+ } catch (e) {
197
+ const err = e instanceof Error ? e : new Error(String(e));
198
+ debugWarn('post-done file write failed:', err.message);
199
+ pending.reject(err);
200
+ setError(err);
201
+ setStatus('error');
202
+ }
203
+ })();
204
+ break;
205
+ }
206
+
207
+ case 'error': {
208
+ debugWarn('WV→RN error', msg.requestId, msg.message);
209
+ sessionRef.current = null;
210
+ const pending = pendingRef.current;
211
+ pendingRef.current = null;
212
+ const err = new Error(msg.message);
213
+ pending?.reject(err);
214
+ setError(err);
215
+ setStatus('error');
216
+ break;
217
+ }
218
+ }
219
+ },
220
+ [failConvert, postAdd, postFinalize],
221
+ );
222
+
223
+ const convert = useCallback(
224
+ async (imageUris: string[], options?: ConvertOptions): Promise<ConvertResult> => {
225
+ if (!bridgeRef.current) {
226
+ throw new Error('Image2PdfBridge is not mounted; render <result.Bridge /> first');
227
+ }
228
+ if (status !== 'ready' && status !== 'done' && status !== 'error') {
229
+ throw new Error(
230
+ status === 'idle'
231
+ ? 'Image2PdfBridge is still loading wasm (wait for status=ready)'
232
+ : `Image2PdfBridge is busy (status=${status})`,
233
+ );
234
+ }
235
+ if (imageUris.length === 0) {
236
+ throw new Error('at least one image is required');
237
+ }
238
+
239
+ const requestId = nextId();
240
+ debugLog('convert start', { requestId, count: imageUris.length, uris: imageUris });
241
+
242
+ setError(null);
243
+ setStatus('adding');
244
+
245
+ return new Promise<ConvertResult>((resolve, reject) => {
246
+ const t0 = Date.now();
247
+ pendingRef.current = {
248
+ resolve: (r) => resolve({ ...r, durationMs: Date.now() - t0 }),
249
+ reject,
250
+ };
251
+ sessionRef.current = {
252
+ requestId,
253
+ uris: imageUris,
254
+ pendingIndex: 0,
255
+ options,
256
+ startedAt: Date.now(),
257
+ };
258
+
259
+ debugLog('RN→WV reset');
260
+ bridgeRef.current!.post({ type: 'reset' });
261
+ void postAdd(sessionRef.current, 0);
262
+ });
263
+ },
264
+ [status, postAdd],
265
+ );
266
+
267
+ // Dev: warn if finalize hangs (wasm / base64 / postMessage).
268
+ useEffect(() => {
269
+ if (status !== 'finalizing') return;
270
+ const session = sessionRef.current;
271
+ const t = setTimeout(() => {
272
+ debugWarn('still finalizing after 30s', {
273
+ requestId: session?.requestId,
274
+ pending: !!pendingRef.current,
275
+ });
276
+ }, 30_000);
277
+ return () => clearTimeout(t);
278
+ }, [status]);
279
+
280
+ const stableHandler = useCallback(handleMessage, [handleMessage]);
281
+ const Bridge = useCallback(
282
+ () => <Image2PdfBridge ref={bridgeRef} onMessage={stableHandler} />,
283
+ [stableHandler],
284
+ );
285
+
286
+ useEffect(() => {
287
+ if (status === 'done' || (status === 'error' && wasmReadyRef.current)) {
288
+ const t = setTimeout(() => {
289
+ setStatus((s) => (s === 'done' || s === 'error' ? 'ready' : s));
290
+ }, 200);
291
+ return () => clearTimeout(t);
292
+ }
293
+ }, [status]);
294
+
295
+ return { convert, Bridge, status, error, result };
296
+ }