react-native-rectangle-doc-scanner 3.27.0 → 3.31.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/dist/CropEditor.js +11 -6
- package/dist/DocScanner.d.ts +3 -0
- package/dist/DocScanner.js +45 -17
- package/dist/FullDocScanner.js +201 -38
- package/dist/types.d.ts +1 -0
- package/dist/utils/overlay.js +25 -105
- package/package.json +1 -1
- package/src/CropEditor.tsx +15 -8
- package/src/DocScanner.tsx +52 -17
- package/src/FullDocScanner.tsx +288 -55
- package/src/types.ts +1 -0
- package/src/utils/overlay.tsx +39 -158
package/src/FullDocScanner.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ActivityIndicator,
|
|
4
4
|
Alert,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { DocScanner } from './DocScanner';
|
|
13
13
|
import { CropEditor } from './CropEditor';
|
|
14
14
|
import type { CapturedDocument, Point, Quad, Rectangle } from './types';
|
|
15
|
-
import type { DetectionConfig } from './DocScanner';
|
|
15
|
+
import type { DetectionConfig, DocScannerHandle, DocScannerCapture } from './DocScanner';
|
|
16
16
|
import { quadToRectangle, scaleRectangle } from './utils/coordinate';
|
|
17
17
|
|
|
18
18
|
type CustomCropManagerType = {
|
|
@@ -34,6 +34,39 @@ const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
|
|
|
34
34
|
|
|
35
35
|
const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
|
|
36
36
|
|
|
37
|
+
const createFullImageRectangle = (width: number, height: number): Rectangle => ({
|
|
38
|
+
topLeft: { x: 0, y: 0 },
|
|
39
|
+
topRight: { x: width, y: 0 },
|
|
40
|
+
bottomRight: { x: width, y: height },
|
|
41
|
+
bottomLeft: { x: 0, y: height },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const resolveImageSize = (
|
|
45
|
+
path: string,
|
|
46
|
+
fallbackWidth: number,
|
|
47
|
+
fallbackHeight: number,
|
|
48
|
+
): Promise<{ width: number; height: number }> =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
Image.getSize(
|
|
51
|
+
ensureFileUri(path),
|
|
52
|
+
(width, height) => resolve({ width, height }),
|
|
53
|
+
() => resolve({
|
|
54
|
+
width: fallbackWidth > 0 ? fallbackWidth : 0,
|
|
55
|
+
height: fallbackHeight > 0 ? fallbackHeight : 0,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const normalizeCapturedDocument = (document: DocScannerCapture): CapturedDocument => {
|
|
61
|
+
const normalizedPath = stripFileUri(document.initialPath ?? document.path);
|
|
62
|
+
return {
|
|
63
|
+
...document,
|
|
64
|
+
path: normalizedPath,
|
|
65
|
+
initialPath: document.initialPath ? stripFileUri(document.initialPath) : normalizedPath,
|
|
66
|
+
croppedPath: document.croppedPath ? stripFileUri(document.croppedPath) : null,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
37
70
|
export interface FullDocScannerResult {
|
|
38
71
|
original: CapturedDocument;
|
|
39
72
|
rectangle: Rectangle | null;
|
|
@@ -90,6 +123,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
90
123
|
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
|
91
124
|
const [processing, setProcessing] = useState(false);
|
|
92
125
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
126
|
+
const docScannerRef = useRef<DocScannerHandle | null>(null);
|
|
127
|
+
const manualCapturePending = useRef(false);
|
|
128
|
+
const processingCaptureRef = useRef(false);
|
|
129
|
+
const cropInitializedRef = useRef(false);
|
|
93
130
|
|
|
94
131
|
const mergedStrings = useMemo<Required<FullDocScannerStrings>>(
|
|
95
132
|
() => ({
|
|
@@ -116,38 +153,52 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
116
153
|
);
|
|
117
154
|
}, [capturedDoc]);
|
|
118
155
|
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!capturedDoc || !imageSize || cropInitializedRef.current) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
|
|
162
|
+
const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : imageSize.height;
|
|
163
|
+
|
|
164
|
+
let initialRectangle: Rectangle | null = null;
|
|
165
|
+
|
|
166
|
+
if (capturedDoc.rectangle) {
|
|
167
|
+
initialRectangle = scaleRectangle(
|
|
168
|
+
capturedDoc.rectangle,
|
|
169
|
+
baseWidth,
|
|
170
|
+
baseHeight,
|
|
171
|
+
imageSize.width,
|
|
172
|
+
imageSize.height,
|
|
173
|
+
);
|
|
174
|
+
} else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
|
|
175
|
+
const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
|
|
176
|
+
if (quadRectangle) {
|
|
177
|
+
initialRectangle = scaleRectangle(
|
|
178
|
+
quadRectangle,
|
|
179
|
+
baseWidth,
|
|
180
|
+
baseHeight,
|
|
181
|
+
imageSize.width,
|
|
182
|
+
imageSize.height,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (initialRectangle) {
|
|
188
|
+
cropInitializedRef.current = true;
|
|
189
|
+
setCropRectangle(initialRectangle);
|
|
190
|
+
}
|
|
191
|
+
}, [capturedDoc, imageSize]);
|
|
192
|
+
|
|
119
193
|
const resetState = useCallback(() => {
|
|
120
194
|
setScreen('scanner');
|
|
121
195
|
setCapturedDoc(null);
|
|
122
196
|
setCropRectangle(null);
|
|
123
197
|
setImageSize(null);
|
|
124
198
|
setProcessing(false);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
(document: CapturedDocument) => {
|
|
129
|
-
const normalizedPath = stripFileUri(document.path);
|
|
130
|
-
const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
|
|
131
|
-
const normalizedInitial =
|
|
132
|
-
document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
|
|
133
|
-
const normalizedCropped =
|
|
134
|
-
document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
|
|
135
|
-
|
|
136
|
-
setCapturedDoc({
|
|
137
|
-
...document,
|
|
138
|
-
path: normalizedPath,
|
|
139
|
-
initialPath: normalizedInitial,
|
|
140
|
-
croppedPath: normalizedCropped,
|
|
141
|
-
quad: nextQuad,
|
|
142
|
-
});
|
|
143
|
-
setCropRectangle(nextQuad ? quadToRectangle(nextQuad) : null);
|
|
144
|
-
setScreen('crop');
|
|
145
|
-
},
|
|
146
|
-
[],
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
const handleCropChange = useCallback((rectangle: Rectangle) => {
|
|
150
|
-
setCropRectangle(rectangle);
|
|
199
|
+
manualCapturePending.current = false;
|
|
200
|
+
processingCaptureRef.current = false;
|
|
201
|
+
cropInitializedRef.current = false;
|
|
151
202
|
}, []);
|
|
152
203
|
|
|
153
204
|
const emitError = useCallback(
|
|
@@ -161,7 +212,142 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
161
212
|
[onError],
|
|
162
213
|
);
|
|
163
214
|
|
|
164
|
-
const
|
|
215
|
+
const processAutoCapture = useCallback(
|
|
216
|
+
async (document: DocScannerCapture) => {
|
|
217
|
+
manualCapturePending.current = false;
|
|
218
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
219
|
+
const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
|
|
220
|
+
|
|
221
|
+
if (!cropManager?.crop) {
|
|
222
|
+
emitError(new Error('CustomCropManager.crop is not available'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setProcessing(true);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const size = await resolveImageSize(
|
|
230
|
+
normalizedDoc.path,
|
|
231
|
+
normalizedDoc.width,
|
|
232
|
+
normalizedDoc.height,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const targetWidthRaw = size.width > 0 ? size.width : normalizedDoc.width;
|
|
236
|
+
const targetHeightRaw = size.height > 0 ? size.height : normalizedDoc.height;
|
|
237
|
+
const baseWidth = normalizedDoc.width > 0 ? normalizedDoc.width : targetWidthRaw;
|
|
238
|
+
const baseHeight = normalizedDoc.height > 0 ? normalizedDoc.height : targetHeightRaw;
|
|
239
|
+
const targetWidth = targetWidthRaw > 0 ? targetWidthRaw : baseWidth || 1;
|
|
240
|
+
const targetHeight = targetHeightRaw > 0 ? targetHeightRaw : baseHeight || 1;
|
|
241
|
+
|
|
242
|
+
let rectangleBase: Rectangle | null = normalizedDoc.rectangle ?? null;
|
|
243
|
+
if (!rectangleBase && normalizedDoc.quad && normalizedDoc.quad.length === 4) {
|
|
244
|
+
rectangleBase = quadToRectangle(normalizedDoc.quad as Quad);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const scaledRectangle = rectangleBase
|
|
248
|
+
? scaleRectangle(
|
|
249
|
+
rectangleBase,
|
|
250
|
+
baseWidth || targetWidth,
|
|
251
|
+
baseHeight || targetHeight,
|
|
252
|
+
targetWidth,
|
|
253
|
+
targetHeight,
|
|
254
|
+
)
|
|
255
|
+
: null;
|
|
256
|
+
|
|
257
|
+
const rectangleToUse = scaledRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
258
|
+
|
|
259
|
+
const base64 = await new Promise<string>((resolve, reject) => {
|
|
260
|
+
cropManager.crop(
|
|
261
|
+
{
|
|
262
|
+
topLeft: rectangleToUse.topLeft,
|
|
263
|
+
topRight: rectangleToUse.topRight,
|
|
264
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
265
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
266
|
+
width: targetWidth,
|
|
267
|
+
height: targetHeight,
|
|
268
|
+
},
|
|
269
|
+
ensureFileUri(normalizedDoc.path),
|
|
270
|
+
(error: unknown, result: { image: string }) => {
|
|
271
|
+
if (error) {
|
|
272
|
+
reject(error instanceof Error ? error : new Error('Crop failed'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve(result.image);
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const finalDoc: CapturedDocument = {
|
|
281
|
+
...normalizedDoc,
|
|
282
|
+
rectangle: rectangleToUse,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
onResult({
|
|
286
|
+
original: finalDoc,
|
|
287
|
+
rectangle: rectangleToUse,
|
|
288
|
+
base64,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
resetState();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
setProcessing(false);
|
|
294
|
+
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
295
|
+
} finally {
|
|
296
|
+
processingCaptureRef.current = false;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
[emitError, onResult, resetState],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const handleCapture = useCallback(
|
|
303
|
+
(document: DocScannerCapture) => {
|
|
304
|
+
if (processingCaptureRef.current) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isManualCapture =
|
|
309
|
+
manualCapture || manualCapturePending.current || document.origin === 'manual';
|
|
310
|
+
|
|
311
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
312
|
+
|
|
313
|
+
if (isManualCapture) {
|
|
314
|
+
manualCapturePending.current = false;
|
|
315
|
+
processingCaptureRef.current = false;
|
|
316
|
+
cropInitializedRef.current = false;
|
|
317
|
+
setCapturedDoc(normalizedDoc);
|
|
318
|
+
setImageSize(null);
|
|
319
|
+
setCropRectangle(null);
|
|
320
|
+
setScreen('crop');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
processingCaptureRef.current = true;
|
|
325
|
+
processAutoCapture(document);
|
|
326
|
+
},
|
|
327
|
+
[manualCapture, processAutoCapture],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const handleCropChange = useCallback((rectangle: Rectangle) => {
|
|
331
|
+
setCropRectangle(rectangle);
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
const triggerManualCapture = useCallback(() => {
|
|
335
|
+
if (processingCaptureRef.current) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
manualCapturePending.current = true;
|
|
339
|
+
const capturePromise = docScannerRef.current?.capture();
|
|
340
|
+
if (capturePromise && typeof capturePromise.catch === 'function') {
|
|
341
|
+
capturePromise.catch((error: unknown) => {
|
|
342
|
+
manualCapturePending.current = false;
|
|
343
|
+
console.warn('[FullDocScanner] manual capture failed', error);
|
|
344
|
+
});
|
|
345
|
+
} else if (!capturePromise) {
|
|
346
|
+
manualCapturePending.current = false;
|
|
347
|
+
}
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
const performCrop = useCallback(async (): Promise<{ base64: string; rectangle: Rectangle }> => {
|
|
165
351
|
if (!capturedDoc) {
|
|
166
352
|
throw new Error('No captured document to crop');
|
|
167
353
|
}
|
|
@@ -173,32 +359,45 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
173
359
|
throw new Error('CustomCropManager.crop is not available');
|
|
174
360
|
}
|
|
175
361
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
362
|
+
const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
|
|
363
|
+
const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
|
|
364
|
+
const targetWidth = size.width > 0 ? size.width : baseWidth || 1;
|
|
365
|
+
const targetHeight = size.height > 0 ? size.height : baseHeight || 1;
|
|
180
366
|
|
|
181
|
-
|
|
182
|
-
? scaleRectangle(
|
|
183
|
-
fallbackRectangle,
|
|
184
|
-
capturedDoc.width,
|
|
185
|
-
capturedDoc.height,
|
|
186
|
-
size.width,
|
|
187
|
-
size.height,
|
|
188
|
-
)
|
|
189
|
-
: null;
|
|
367
|
+
let fallbackRectangle: Rectangle | null = null;
|
|
190
368
|
|
|
191
|
-
|
|
369
|
+
if (capturedDoc.rectangle) {
|
|
370
|
+
fallbackRectangle = scaleRectangle(
|
|
371
|
+
capturedDoc.rectangle,
|
|
372
|
+
baseWidth || targetWidth,
|
|
373
|
+
baseHeight || targetHeight,
|
|
374
|
+
targetWidth,
|
|
375
|
+
targetHeight,
|
|
376
|
+
);
|
|
377
|
+
} else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
|
|
378
|
+
const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
|
|
379
|
+
if (quadRectangle) {
|
|
380
|
+
fallbackRectangle = scaleRectangle(
|
|
381
|
+
quadRectangle,
|
|
382
|
+
baseWidth || targetWidth,
|
|
383
|
+
baseHeight || targetHeight,
|
|
384
|
+
targetWidth,
|
|
385
|
+
targetHeight,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const rectangleToUse = cropRectangle ?? fallbackRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
192
391
|
|
|
193
392
|
const base64 = await new Promise<string>((resolve, reject) => {
|
|
194
393
|
cropManager.crop(
|
|
195
394
|
{
|
|
196
|
-
topLeft:
|
|
197
|
-
topRight:
|
|
198
|
-
bottomRight:
|
|
199
|
-
bottomLeft:
|
|
200
|
-
width:
|
|
201
|
-
height:
|
|
395
|
+
topLeft: rectangleToUse.topLeft,
|
|
396
|
+
topRight: rectangleToUse.topRight,
|
|
397
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
398
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
399
|
+
width: targetWidth,
|
|
400
|
+
height: targetHeight,
|
|
202
401
|
},
|
|
203
402
|
ensureFileUri(capturedDoc.path),
|
|
204
403
|
(error: unknown, result: { image: string }) => {
|
|
@@ -212,7 +411,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
212
411
|
);
|
|
213
412
|
});
|
|
214
413
|
|
|
215
|
-
return base64;
|
|
414
|
+
return { base64, rectangle: rectangleToUse };
|
|
216
415
|
}, [capturedDoc, cropRectangle, imageSize]);
|
|
217
416
|
|
|
218
417
|
const handleConfirm = useCallback(async () => {
|
|
@@ -222,11 +421,15 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
222
421
|
|
|
223
422
|
try {
|
|
224
423
|
setProcessing(true);
|
|
225
|
-
const base64 = await performCrop();
|
|
424
|
+
const { base64, rectangle } = await performCrop();
|
|
226
425
|
setProcessing(false);
|
|
426
|
+
const finalDoc: CapturedDocument = {
|
|
427
|
+
...capturedDoc,
|
|
428
|
+
rectangle,
|
|
429
|
+
};
|
|
227
430
|
onResult({
|
|
228
|
-
original:
|
|
229
|
-
rectangle
|
|
431
|
+
original: finalDoc,
|
|
432
|
+
rectangle,
|
|
230
433
|
base64,
|
|
231
434
|
});
|
|
232
435
|
resetState();
|
|
@@ -234,7 +437,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
234
437
|
setProcessing(false);
|
|
235
438
|
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
236
439
|
}
|
|
237
|
-
}, [capturedDoc,
|
|
440
|
+
}, [capturedDoc, emitError, onResult, performCrop, resetState]);
|
|
238
441
|
|
|
239
442
|
const handleRetake = useCallback(() => {
|
|
240
443
|
resetState();
|
|
@@ -250,6 +453,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
250
453
|
{screen === 'scanner' && (
|
|
251
454
|
<View style={styles.flex}>
|
|
252
455
|
<DocScanner
|
|
456
|
+
ref={docScannerRef}
|
|
253
457
|
autoCapture={!manualCapture}
|
|
254
458
|
overlayColor={overlayColor}
|
|
255
459
|
showGrid={showGrid}
|
|
@@ -270,8 +474,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
270
474
|
</TouchableOpacity>
|
|
271
475
|
<View style={styles.instructions} pointerEvents="none">
|
|
272
476
|
<Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
|
|
273
|
-
|
|
477
|
+
<Text style={styles.captureText}>{mergedStrings.manualHint}</Text>
|
|
274
478
|
</View>
|
|
479
|
+
<TouchableOpacity
|
|
480
|
+
style={[styles.shutterButton, processing && styles.shutterButtonDisabled]}
|
|
481
|
+
onPress={triggerManualCapture}
|
|
482
|
+
disabled={processing}
|
|
483
|
+
accessibilityLabel={mergedStrings.manualHint}
|
|
484
|
+
accessibilityRole="button"
|
|
485
|
+
>
|
|
486
|
+
<View style={styles.shutterInner} />
|
|
487
|
+
</TouchableOpacity>
|
|
275
488
|
</View>
|
|
276
489
|
</DocScanner>
|
|
277
490
|
</View>
|
|
@@ -349,6 +562,26 @@ const styles = StyleSheet.create({
|
|
|
349
562
|
fontSize: 15,
|
|
350
563
|
textAlign: 'center',
|
|
351
564
|
},
|
|
565
|
+
shutterButton: {
|
|
566
|
+
alignSelf: 'center',
|
|
567
|
+
width: 80,
|
|
568
|
+
height: 80,
|
|
569
|
+
borderRadius: 40,
|
|
570
|
+
borderWidth: 4,
|
|
571
|
+
borderColor: '#fff',
|
|
572
|
+
justifyContent: 'center',
|
|
573
|
+
alignItems: 'center',
|
|
574
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
575
|
+
},
|
|
576
|
+
shutterButtonDisabled: {
|
|
577
|
+
opacity: 0.4,
|
|
578
|
+
},
|
|
579
|
+
shutterInner: {
|
|
580
|
+
width: 60,
|
|
581
|
+
height: 60,
|
|
582
|
+
borderRadius: 30,
|
|
583
|
+
backgroundColor: '#fff',
|
|
584
|
+
},
|
|
352
585
|
cropFooter: {
|
|
353
586
|
position: 'absolute',
|
|
354
587
|
bottom: 40,
|