@dile/scanner 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/scanner",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Web Component to scan documents in the browser: edge detection, perspective correction and multipage PDF or JPEG output, powered by scanic and jsPDF",
5
5
  "keywords": [
6
6
  "scanner",
@@ -29,12 +29,12 @@
29
29
  "url": "https://github.com/Polydile/dile-components/issues"
30
30
  },
31
31
  "dependencies": {
32
- "@dile/iconlib": "^2.0.1",
33
- "@dile/ui": "^3.18.0",
32
+ "@dile/iconlib": "^2.0.2",
33
+ "@dile/ui": "^3.18.2",
34
34
  "jspdf": "^4.2.1",
35
35
  "lit": "^2.7.0 || ^3.0.0",
36
36
  "scanic": "1.7.1",
37
37
  "scanic-ml": "0.2.0"
38
38
  },
39
- "gitHead": "1b065736666cc26f8afc1106ec7ffc98feaec60e"
39
+ "gitHead": "b59afd879d78118d5ea1cdc53e144f1a84628f13"
40
40
  }
@@ -1,78 +0,0 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
2
- import './scanner.js';
3
- import {
4
- hardDocumentCanvas,
5
- canvasToFile,
6
- documentFile,
7
- openScanner,
8
- selectFiles,
9
- acceptAdjust,
10
- waitForPages,
11
- save,
12
- imageSize,
13
- } from './test/scanner-test-helpers.js';
14
-
15
- // The ML detector files are served by the scannerMlAssets() Vite plugin
16
- // (vite-plugin.js), registered in vitest.config.js. This file runs in its own
17
- // page, so the resource timing entries only contain what these tests load.
18
- const ML_ASSETS_URL = '/dile-scanner-ml/';
19
- const ML_RESOURCE = /scanic-mlDetector|scanic-ort|ort-wasm-simd-threaded|doccornernet_lean/;
20
-
21
- const mlResources = () =>
22
- performance
23
- .getEntriesByType('resource')
24
- .map((entry) => entry.name)
25
- .filter((name) => ML_RESOURCE.test(name));
26
-
27
- async function scanPageSize(attrs, file) {
28
- const el = await openScanner(attrs);
29
- selectFiles(el, [file]);
30
- await acceptAdjust(el);
31
- await waitForPages(el, 1);
32
- const detail = await save(el);
33
- return imageSize(detail.pages[0]);
34
- }
35
-
36
- describe('dile-scanner ML detector', () => {
37
- afterEach(() => {
38
- document.body.innerHTML = '';
39
- });
40
-
41
- // Must run first: once loaded, the ML files stay in the resource entries.
42
- it('does not load any ML file without ml-detector', async () => {
43
- await scanPageSize('', await documentFile());
44
-
45
- expect(mlResources()).toEqual([]);
46
- });
47
-
48
- it('loads the ML files only when the first photo is processed', async () => {
49
- const el = await openScanner(`ml-detector ml-assets-url="${ML_ASSETS_URL}"`);
50
- await new Promise((resolve) => setTimeout(resolve, 300));
51
-
52
- expect(mlResources()).toEqual([]);
53
-
54
- selectFiles(el, [await documentFile()]);
55
- await acceptAdjust(el);
56
- await waitForPages(el, 1);
57
- const assets = mlResources().filter((name) => !name.includes('/node_modules/'));
58
-
59
- expect(assets.map((name) => new URL(name).pathname)).toEqual(
60
- expect.arrayContaining([
61
- `${ML_ASSETS_URL}doccornernet_lean.ort`,
62
- `${ML_ASSETS_URL}ort-wasm-simd-threaded.mjs`,
63
- `${ML_ASSETS_URL}ort-wasm-simd-threaded.wasm`,
64
- ]),
65
- );
66
- expect(assets.some((name) => name.includes('jsdelivr'))).toBe(false);
67
- }, 30000);
68
-
69
- it('finds the document where the classical detector picks a line of text', async () => {
70
- const file = await canvasToFile(hardDocumentCanvas());
71
- const classical = await scanPageSize('', file);
72
- const ml = await scanPageSize(`ml-detector ml-assets-url="${ML_ASSETS_URL}"`, file);
73
-
74
- expect(classical.height).toBeLessThan(100);
75
- expect(ml.width).toBeGreaterThan(750);
76
- expect(ml.height).toBeGreaterThan(600);
77
- }, 30000);
78
- });
package/scanner.spec.js DELETED
@@ -1,436 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from 'vitest';
2
- import './scanner.js';
3
- import {
4
- documentCanvas,
5
- canvasToFile,
6
- documentFile,
7
- waitFor,
8
- nextEvent,
9
- imageSize,
10
- renderScanner,
11
- openScanner,
12
- query,
13
- queryAll,
14
- button,
15
- pageButtons,
16
- pages,
17
- errorMessage,
18
- selectFiles,
19
- waitForIdle,
20
- waitForEditor,
21
- waitForPages,
22
- acceptAdjust,
23
- scanFiles,
24
- save,
25
- } from './test/scanner-test-helpers.js';
26
-
27
- describe('dile-scanner', () => {
28
- afterEach(() => {
29
- document.body.innerHTML = '';
30
- vi.restoreAllMocks();
31
- });
32
-
33
- it('starts on the capture step when opened', async () => {
34
- const el = await openScanner();
35
-
36
- expect(query(el, 'dile-modal').opened).toBe(true);
37
- expect(query(el, 'h2').textContent).toBe('Scan document');
38
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
39
- expect(button(el, 'camera')).toBeNull();
40
- expect(query(el, '#galleryinput').multiple).toBe(false);
41
- });
42
-
43
- it('detects the document and returns a PDF with one page', async () => {
44
- const el = await openScanner('filename="invoice"');
45
- selectFiles(el, [await documentFile()]);
46
- await waitForEditor(el);
47
-
48
- expect(query(el, '.step:not([hidden]) .message').textContent.trim()).toBe(
49
- 'Drag the corners to the edges of the document.',
50
- );
51
-
52
- await acceptAdjust(el);
53
- await waitForPages(el, 1);
54
- const detail = await save(el);
55
-
56
- expect(detail.file.name).toBe('invoice.pdf');
57
- expect(detail.file.type).toBe('application/pdf');
58
- expect(detail.pages).toHaveLength(1);
59
- expect(detail.pages[0].name).toBe('invoice.jpg');
60
- expect(detail.files).toBeUndefined();
61
- expect(query(el, 'dile-modal').opened).toBe(false);
62
- });
63
-
64
- it('names the result with the prefix and the date when there is no filename', async () => {
65
- const el = await openScanner();
66
- await scanFiles(el, [await documentFile()]);
67
- const detail = await save(el);
68
-
69
- expect(detail.file.name).toMatch(/^scan-\d{4}-\d{2}-\d{2}-\d{4}\.pdf$/);
70
- });
71
-
72
- it('adjusts several images one after another and joins them in one PDF', async () => {
73
- const el = await openScanner('multipage');
74
- expect(query(el, '#galleryinput').multiple).toBe(true);
75
-
76
- const files = await Promise.all([documentFile(), documentFile(), documentFile()]);
77
- selectFiles(el, files);
78
- for (let i = 0; i < files.length; i++) {
79
- await waitForEditor(el);
80
- expect(query(el, '.step:not([hidden]) .message').textContent.trim()).toBe(
81
- 'Drag the corners to the edges of the document.',
82
- );
83
- button(el, 'check').click();
84
- await waitForIdle(el);
85
- }
86
- await waitForPages(el, 3);
87
- expect(queryAll(el, '.page-number').map((item) => item.textContent.trim())).toEqual([
88
- 'Page 1',
89
- 'Page 2',
90
- 'Page 3',
91
- ]);
92
-
93
- const detail = await save(el);
94
- const pdf = await detail.file.text();
95
-
96
- expect(pdf.match(/\/Type\s*\/Page(?!s)/g)).toHaveLength(3);
97
- expect(pdf.match(/\/DCTDecode/g)).toHaveLength(3);
98
- expect(detail.pages.map((page) => page.name)).toEqual([
99
- expect.stringMatching(/-1\.jpg$/),
100
- expect.stringMatching(/-2\.jpg$/),
101
- expect.stringMatching(/-3\.jpg$/),
102
- ]);
103
- });
104
-
105
- it('returns one JPEG per page with output="images"', async () => {
106
- const el = await openScanner('multipage output="images" filename="receipt"');
107
- await scanFiles(el, [await documentFile(), await documentFile()]);
108
- const detail = await save(el);
109
-
110
- expect(detail.file).toBeUndefined();
111
- expect(detail.files.map((file) => file.name)).toEqual(['receipt-1.jpg', 'receipt-2.jpg']);
112
- expect(detail.files.every((file) => file.type === 'image/jpeg')).toBe(true);
113
- expect(detail.pages).toEqual(detail.files);
114
- });
115
-
116
- it('returns an array of images even with a single page', async () => {
117
- const el = await openScanner('output="images"');
118
- await scanFiles(el, [await documentFile()]);
119
- const detail = await save(el);
120
-
121
- expect(detail.files).toHaveLength(1);
122
- });
123
-
124
- it('replaces the page with a new image when it is not multipage', async () => {
125
- const el = await openScanner();
126
- selectFiles(el, [await documentFile()]);
127
- await waitForEditor(el);
128
- button(el, 'maximize').click();
129
- await waitForPages(el, 1);
130
-
131
- expect(button(el, 'image').textContent.trim()).toBe('Choose another image');
132
- expect(pageButtons(el, 'trash-2')).toHaveLength(0);
133
-
134
- selectFiles(el, [await canvasToFile(documentCanvas({ width: 800, height: 1000 }))]);
135
- await waitForEditor(el);
136
- button(el, 'maximize').click();
137
- await waitForPages(el, 1);
138
- const detail = await save(el);
139
-
140
- expect(detail.pages).toHaveLength(1);
141
- expect(await imageSize(detail.pages[0])).toEqual({ width: 800, height: 1000 });
142
- });
143
-
144
- it('uses the full image when asked, limited by max-side', async () => {
145
- const el = await openScanner('max-side="1000"');
146
- selectFiles(el, [await documentFile({ width: 1200, height: 900 })]);
147
- await waitForEditor(el);
148
- button(el, 'maximize').click();
149
- await waitForPages(el, 1);
150
- const detail = await save(el);
151
-
152
- expect(await imageSize(detail.pages[0])).toEqual({ width: 1000, height: 750 });
153
- });
154
-
155
- it('applies the EXIF orientation of the photo', async () => {
156
- const el = await openScanner();
157
- const landscape = await documentFile({ width: 1200, height: 900 });
158
- selectFiles(el, [await withExifOrientation(landscape, 6)]);
159
- await waitForEditor(el);
160
- button(el, 'maximize').click();
161
- await waitForPages(el, 1);
162
- const detail = await save(el);
163
-
164
- expect(await imageSize(detail.pages[0])).toEqual({ width: 900, height: 1200 });
165
- });
166
-
167
- it('rotates and reorders pages', async () => {
168
- const el = await openScanner('multipage');
169
- selectFiles(el, [
170
- await documentFile({ width: 1200, height: 900 }),
171
- await canvasToFile(documentCanvas({ width: 600, height: 1000 })),
172
- ]);
173
- for (let i = 0; i < 2; i++) {
174
- await waitForEditor(el);
175
- button(el, 'maximize').click();
176
- await waitForIdle(el);
177
- }
178
- await waitForPages(el, 2);
179
-
180
- // First page: no "move before". Last page: no "move after".
181
- expect(pageButtons(el, 'chevron-left').map((item) => item.disabled)).toEqual([true, false]);
182
- expect(pageButtons(el, 'chevron-right').map((item) => item.disabled)).toEqual([false, true]);
183
-
184
- pageButtons(el, 'rotate-cw')[0].click();
185
- await waitForIdle(el);
186
- pageButtons(el, 'chevron-left')[1].click();
187
- await el.updateComplete;
188
-
189
- const detail = await save(el);
190
- expect(await imageSize(detail.pages[0])).toEqual({ width: 600, height: 1000 });
191
- expect(await imageSize(detail.pages[1])).toEqual({ width: 900, height: 1200 });
192
- });
193
-
194
- it('goes back to the capture step when the last page is removed', async () => {
195
- const el = await openScanner('multipage');
196
- await scanFiles(el, [await documentFile(), await documentFile()]);
197
-
198
- pageButtons(el, 'trash-2')[0].click();
199
- await el.updateComplete;
200
- expect(pages(el)).toHaveLength(1);
201
-
202
- pageButtons(el, 'trash-2')[0].click();
203
- await el.updateComplete;
204
- expect(pages(el)).toHaveLength(0);
205
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
206
- });
207
-
208
- it('keeps the page when its re-adjustment is cancelled', async () => {
209
- const el = await openScanner('multipage');
210
- await scanFiles(el, [await documentFile(), await documentFile()]);
211
-
212
- pageButtons(el, 'crop')[1].click();
213
- await waitForEditor(el);
214
- button(el, 'x').click();
215
- await waitForPages(el, 2);
216
-
217
- expect(query(el, '#editor')).toBeNull();
218
- });
219
-
220
- it('replaces the page with the re-adjusted one', async () => {
221
- const el = await openScanner();
222
- selectFiles(el, [await documentFile({ width: 1200, height: 900 })]);
223
- await acceptAdjust(el);
224
- await waitForPages(el, 1);
225
-
226
- pageButtons(el, 'crop')[0].click();
227
- await waitForEditor(el);
228
- button(el, 'maximize').click();
229
- await waitForPages(el, 1);
230
- const detail = await save(el);
231
-
232
- expect(await imageSize(detail.pages[0])).toEqual({ width: 1200, height: 900 });
233
- });
234
-
235
- it('warns when the edges of the document are not detected', async () => {
236
- const el = await openScanner();
237
- const blank = document.createElement('canvas');
238
- blank.width = 800;
239
- blank.height = 600;
240
- const context = blank.getContext('2d');
241
- context.fillStyle = '#888';
242
- context.fillRect(0, 0, blank.width, blank.height);
243
- selectFiles(el, [await canvasToFile(blank)]);
244
- await waitForEditor(el);
245
-
246
- expect(query(el, '.step:not([hidden]) .message').textContent.trim()).toBe(
247
- 'The edges of the document were not detected. Drag the corners to the edges of the document.',
248
- );
249
- });
250
-
251
- it('shows an error and stays on the capture step with an unreadable file', async () => {
252
- const el = await openScanner();
253
- selectFiles(el, [new File(['not an image'], 'broken.jpg', { type: 'image/jpeg' })]);
254
- await waitFor(() => errorMessage(el));
255
-
256
- expect(errorMessage(el)).toBe(
257
- 'The image could not be read. Try with another image (JPG, PNG or WebP).',
258
- );
259
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
260
- expect(query(el, '#editor')).toBeNull();
261
- });
262
-
263
- it('disables adding pages and refuses to save when max-bytes is reached', async () => {
264
- const el = await openScanner('multipage max-bytes="1000"');
265
- const scanned = vi.fn();
266
- el.addEventListener('dile-document-scanned', scanned);
267
- await scanFiles(el, [await documentFile()]);
268
-
269
- expect(query(el, '.size').textContent.replace(/\s+/g, ' ').trim()).toMatch(/^\d+ KB of 1 KB$/);
270
- expect(button(el, 'image').disabled).toBe(true);
271
- expect(query(el, '.step:not([hidden]) p.message').textContent.trim()).toBe(
272
- 'The document has reached the maximum size. Remove a page to add another one.',
273
- );
274
-
275
- button(el, 'check').click();
276
- await waitFor(() => errorMessage(el));
277
-
278
- expect(errorMessage(el)).toBe(
279
- 'The document exceeds the maximum size even with reduced quality. Remove some pages.',
280
- );
281
- expect(scanned).not.toHaveBeenCalled();
282
- expect(query(el, 'dile-modal').opened).toBe(true);
283
- });
284
-
285
- it('compresses the pages more to fit a max-bytes a bit lower than the document', async () => {
286
- const file = await documentFile();
287
- const reference = await openScanner();
288
- await scanFiles(reference, [file]);
289
- const fullSize = (await save(reference)).file.size;
290
-
291
- const maxBytes = Math.floor(fullSize * 0.9);
292
- const el = await openScanner(`max-bytes="${maxBytes}"`);
293
- await scanFiles(el, [file]);
294
- const detail = await save(el);
295
-
296
- expect(detail.file.size).toBeLessThanOrEqual(maxBytes);
297
- });
298
-
299
- it('dispatches the cancel event when closed without saving', async () => {
300
- const el = await openScanner();
301
- const cancelled = vi.fn();
302
- const modalClosed = vi.fn();
303
- el.addEventListener('dile-document-scan-cancelled', cancelled);
304
- document.addEventListener('dile-modal-closed', modalClosed);
305
-
306
- button(el, 'x').click();
307
- document.removeEventListener('dile-modal-closed', modalClosed);
308
-
309
- expect(cancelled).toHaveBeenCalledTimes(1);
310
- expect(modalClosed).not.toHaveBeenCalled();
311
- expect(query(el, 'dile-modal').opened).toBe(false);
312
- });
313
-
314
- it('dispatches the cancel event when closed with the Esc key', async () => {
315
- const el = await openScanner();
316
- const cancelled = vi.fn();
317
- el.addEventListener('dile-document-scan-cancelled', cancelled);
318
-
319
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
320
-
321
- expect(cancelled).toHaveBeenCalledTimes(1);
322
- expect(query(el, 'dile-modal').opened).toBe(false);
323
- });
324
-
325
- it('does not dispatch the cancel event after saving', async () => {
326
- const el = await openScanner();
327
- const cancelled = vi.fn();
328
- el.addEventListener('dile-document-scan-cancelled', cancelled);
329
- await scanFiles(el, [await documentFile()]);
330
- await save(el);
331
-
332
- expect(cancelled).not.toHaveBeenCalled();
333
- });
334
-
335
- it('discards the work in progress when closed while processing', async () => {
336
- const el = await openScanner();
337
- const cancelled = vi.fn();
338
- const scanned = vi.fn();
339
- el.addEventListener('dile-document-scan-cancelled', cancelled);
340
- el.addEventListener('dile-document-scanned', scanned);
341
-
342
- selectFiles(el, [await documentFile()]);
343
- await el.updateComplete;
344
- expect(query(el, '.busy')).toBeTruthy();
345
- el.close();
346
- await new Promise((resolve) => setTimeout(resolve, 1000));
347
-
348
- expect(cancelled).toHaveBeenCalledTimes(1);
349
- expect(scanned).not.toHaveBeenCalled();
350
- expect(query(el, '.scanic-handle')).toBeNull();
351
- expect(query(el, '.busy')).toBeNull();
352
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
353
- });
354
-
355
- it('starts with an empty document when opened again', async () => {
356
- const el = await openScanner();
357
- await scanFiles(el, [await documentFile()]);
358
- el.close();
359
- el.open();
360
- await el.updateComplete;
361
-
362
- expect(pages(el)).toHaveLength(0);
363
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
364
- });
365
-
366
- it('merges the labels with the default ones, also in the corner handles', async () => {
367
- const el = await renderScanner();
368
- el.labels = { title: 'Escanear documento', topLeftCorner: 'Esquina superior izquierda' };
369
- el.open();
370
- await el.updateComplete;
371
-
372
- expect(query(el, 'h2').textContent).toBe('Escanear documento');
373
- expect(button(el, 'image').textContent.trim()).toBe('Choose image');
374
-
375
- selectFiles(el, [await documentFile()]);
376
- await waitForEditor(el);
377
- const handleLabels = queryAll(el, '.scanic-handle').map((handle) => handle.getAttribute('aria-label'));
378
-
379
- expect(handleLabels).toContain('Esquina superior izquierda');
380
- expect(handleLabels).toContain('Bottom-right corner');
381
- });
382
-
383
- it('offers to take a photo on touch devices', async () => {
384
- const realMatchMedia = window.matchMedia.bind(window);
385
- vi.spyOn(window, 'matchMedia').mockImplementation((mediaQuery) =>
386
- mediaQuery === '(pointer: coarse)' ? { matches: true } : realMatchMedia(mediaQuery),
387
- );
388
- const el = await openScanner();
389
- const camera = query(el, '#camerainput');
390
- const openCamera = vi.spyOn(camera, 'click').mockImplementation(() => {});
391
-
392
- expect(button(el, 'camera').textContent.trim()).toBe('Take photo');
393
- expect(camera.getAttribute('capture')).toBe('environment');
394
-
395
- button(el, 'camera').click();
396
- expect(openCamera).toHaveBeenCalled();
397
-
398
- selectFiles(el, [await documentFile()], 'camerainput');
399
- await acceptAdjust(el);
400
- await waitForPages(el, 1);
401
- expect(button(el, 'camera').textContent.trim()).toBe('Repeat photo');
402
- });
403
-
404
- it('falls back to the classical detector when the ML assets cannot be loaded', async () => {
405
- const fetchSpy = vi.spyOn(window, 'fetch');
406
- const el = await openScanner('ml-detector ml-assets-url="http://127.0.0.1:9/missing/"');
407
- selectFiles(el, [await documentFile()]);
408
- await waitForEditor(el);
409
-
410
- expect(fetchSpy).toHaveBeenCalledWith(
411
- expect.stringContaining('http://127.0.0.1:9/missing/'),
412
- expect.anything(),
413
- );
414
-
415
- expect(errorMessage(el)).toBeUndefined();
416
- expect(query(el, '.step:not([hidden]) .message').textContent.trim()).toBe(
417
- 'Drag the corners to the edges of the document.',
418
- );
419
- }, 30000);
420
- });
421
-
422
- // Inserts an EXIF APP1 segment with the given orientation after the JPEG SOI.
423
- async function withExifOrientation(file, orientation) {
424
- const jpeg = new Uint8Array(await file.arrayBuffer());
425
- const tiff = [
426
- 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, // little endian, IFD at 8
427
- 0x01, 0x00, // one entry
428
- 0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, orientation, 0x00, 0x00, 0x00, // Orientation
429
- 0x00, 0x00, 0x00, 0x00, // no next IFD
430
- ];
431
- const payload = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00, ...tiff]; // "Exif\0\0"
432
- const length = payload.length + 2;
433
- const app1 = [0xff, 0xe1, length >> 8, length & 0xff, ...payload];
434
- const bytes = new Uint8Array([...jpeg.slice(0, 2), ...app1, ...jpeg.slice(2)]);
435
- return new File([bytes], file.name, { type: 'image/jpeg' });
436
- }