@dile/scanner 0.0.0-stage → 0.0.2
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/LICENSE +9 -0
- package/README.md +41 -2
- package/index.js +2 -0
- package/package.json +38 -4
- package/scanner-ml.spec.js +78 -0
- package/scanner.js +5 -0
- package/scanner.spec.js +436 -0
- package/src/DileScanner.js +877 -0
- package/src/corner-editor-styles.js +101 -0
- package/src/default-labels.js +41 -0
- package/src/image-utils.js +86 -0
- package/src/pdf-builder.js +31 -0
- package/src/scanner-engine.js +98 -0
- package/test/scanner-test-helpers.js +183 -0
- package/vite-plugin.js +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 EscuelaIT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dile/scanner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web Component to scan documents in the browser: edge detection, perspective correction and multipage PDF or JPEG output, powered by [scanic](https://www.npmjs.com/package/scanic) and [jsPDF](https://www.npmjs.com/package/jspdf).
|
|
4
|
+
|
|
5
|
+
Everything runs on the client: no backend needed. The component does not upload anything; it dispatches an event with the resulting files.
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<dile-scanner multipage max-bytes="5242880"></dile-scanner>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```javascript
|
|
12
|
+
import '@dile/scanner/scanner.js';
|
|
13
|
+
|
|
14
|
+
const scanner = document.querySelector('dile-scanner');
|
|
15
|
+
scanner.addEventListener('dile-document-scanned', (e) => upload(e.detail.file));
|
|
16
|
+
scanner.open();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Docs and demos
|
|
20
|
+
|
|
21
|
+
Read the docs and find demos on the [dile-components site](https://dile-components.com/components/dile-scanner/)
|
|
22
|
+
|
|
23
|
+
## ML detector files
|
|
24
|
+
|
|
25
|
+
The ML detector is optional and off by default. Without `ml-detector` your users download nothing of it. With `ml-detector` the component uses scanic's neural detector, and the browser downloads its files (3.5 MB, about 2.2 MB with gzip) the first time a photo is processed.
|
|
26
|
+
|
|
27
|
+
By default the files come from jsDelivr. The package installs the matching `scanic-ml` version (3.5 MB in `node_modules`, only disk space in your project), so you can serve your own copy with the included Vite plugin:
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
// vite.config.js
|
|
31
|
+
import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
|
|
32
|
+
|
|
33
|
+
export default { plugins: [scannerMlAssets()] };
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Maintenance note
|
|
41
|
+
|
|
42
|
+
`scanic` is pinned to an exact version because `src/corner-editor-styles.js` copies the styles of its corner editor (scanic injects them into `document.head`, which does not reach the shadow root). When updating scanic, review that copy against `node_modules/scanic/dist/scanic.js` (search `.scanic-corner-editor`) and update `scanic-ml` (also pinned) to the version that scanic expects (see the jsDelivr URL in `node_modules/scanic/dist/scanic-mlDetector.js`).
|
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/scanner",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"scanner",
|
|
7
|
+
"document scanner",
|
|
8
|
+
"pdf",
|
|
9
|
+
"perspective correction",
|
|
10
|
+
"Lit",
|
|
11
|
+
"Web Component"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"author": "midesweb <miguel@desarrolloweb.com>",
|
|
15
|
+
"homepage": "https://dile-components.com/",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"main": "index.js",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Polydile/dile-components.git"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/Polydile/dile-components/issues"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@dile/iconlib": "^2.0.1",
|
|
33
|
+
"@dile/ui": "^3.16.2",
|
|
34
|
+
"jspdf": "^4.2.1",
|
|
35
|
+
"lit": "^2.7.0 || ^3.0.0",
|
|
36
|
+
"scanic": "1.7.1",
|
|
37
|
+
"scanic-ml": "0.2.0"
|
|
38
|
+
},
|
|
39
|
+
"gitHead": "c863a2ebefe61959c1a939ff3ebf46a663e33bb7"
|
|
40
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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.js
ADDED
package/scanner.spec.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
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
|
+
}
|