@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
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing } from "lit";
|
|
2
|
+
import "@dile/ui/components/modal/modal.js";
|
|
3
|
+
import "@dile/ui/components/button/button.js";
|
|
4
|
+
import "@dile/ui/components/spinner/spinner.js";
|
|
5
|
+
import "@dile/iconlib/lucide-icons/camera.js";
|
|
6
|
+
import "@dile/iconlib/lucide-icons/image.js";
|
|
7
|
+
import "@dile/iconlib/lucide-icons/check.js";
|
|
8
|
+
import "@dile/iconlib/lucide-icons/x.js";
|
|
9
|
+
import "@dile/iconlib/lucide-icons/undo-2.js";
|
|
10
|
+
import "@dile/iconlib/lucide-icons/maximize.js";
|
|
11
|
+
import "@dile/iconlib/lucide-icons/rotate-cw.js";
|
|
12
|
+
import "@dile/iconlib/lucide-icons/crop.js";
|
|
13
|
+
import "@dile/iconlib/lucide-icons/chevron-left.js";
|
|
14
|
+
import "@dile/iconlib/lucide-icons/chevron-right.js";
|
|
15
|
+
import "@dile/iconlib/lucide-icons/trash-2.js";
|
|
16
|
+
import { DEFAULT_LABELS } from "./default-labels.js";
|
|
17
|
+
import { cornerEditorStyles } from "./corner-editor-styles.js";
|
|
18
|
+
import {
|
|
19
|
+
preloadEngine,
|
|
20
|
+
detectCorners,
|
|
21
|
+
extractDocument,
|
|
22
|
+
createCornerEditor,
|
|
23
|
+
} from "./scanner-engine.js";
|
|
24
|
+
import {
|
|
25
|
+
fileToCanvas,
|
|
26
|
+
blobToCanvas,
|
|
27
|
+
canvasToBlob,
|
|
28
|
+
rotateCanvas,
|
|
29
|
+
encodeJpeg,
|
|
30
|
+
fullImageCorners,
|
|
31
|
+
} from "./image-utils.js";
|
|
32
|
+
import { buildPdf } from "./pdf-builder.js";
|
|
33
|
+
|
|
34
|
+
// Longest side of the original photo kept for detection and re-adjusting.
|
|
35
|
+
// Keeps the canvas under the iOS area limit and bounds memory per page.
|
|
36
|
+
const SOURCE_MAX_SIDE = 3000;
|
|
37
|
+
const SOURCE_QUALITY = 0.92;
|
|
38
|
+
// Smaller encodings tried in order when the document exceeds maxBytes. Each
|
|
39
|
+
// step lowers both the size and the quality, because lowering only the
|
|
40
|
+
// quality saves little (about 15% from 0.8 to 0.7 in Safari).
|
|
41
|
+
const SIZE_FALLBACKS = [
|
|
42
|
+
{ maxSide: 1600, quality: 0.7 },
|
|
43
|
+
{ maxSide: 1400, quality: 0.6 },
|
|
44
|
+
{ maxSide: 1200, quality: 0.5 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Document scanner: takes photos (or images), detects the edges of the
|
|
48
|
+
// document, lets the user adjust the corners, straightens the perspective and
|
|
49
|
+
// returns the result as a PDF (output="pdf", default) or as JPEG images
|
|
50
|
+
// (output="images"). With "multipage" the user can add several pages, reorder
|
|
51
|
+
// them, rotate them, adjust them again and remove them. With "ml-detector"
|
|
52
|
+
// the edges are detected with scanic's neural detector (about 2.2 MB
|
|
53
|
+
// downloaded with gzip the first time) instead of the classical one; off by
|
|
54
|
+
// default.
|
|
55
|
+
//
|
|
56
|
+
// It does not upload anything: when the user saves, it dispatches
|
|
57
|
+
// "dile-document-scanned" with the files and closes. Closing it without saving
|
|
58
|
+
// dispatches "dile-document-scan-cancelled".
|
|
59
|
+
//
|
|
60
|
+
// <dile-scanner multipage max-bytes="5242880"
|
|
61
|
+
// @dile-document-scanned=${e => upload(e.detail.file)}></dile-scanner>
|
|
62
|
+
// scanner.open();
|
|
63
|
+
export class DileScanner extends LitElement {
|
|
64
|
+
static styles = [
|
|
65
|
+
cornerEditorStyles,
|
|
66
|
+
css`
|
|
67
|
+
* {
|
|
68
|
+
box-sizing: border-box;
|
|
69
|
+
}
|
|
70
|
+
:host {
|
|
71
|
+
display: block;
|
|
72
|
+
--dile-modal-width: var(--dile-scanner-modal-width, min(96vw, 900px));
|
|
73
|
+
--dile-modal-max-width: 100vw;
|
|
74
|
+
--dile-modal-height: auto;
|
|
75
|
+
--dile-modal-max-height: var(--dile-scanner-modal-max-height, 92dvh);
|
|
76
|
+
--dile-modal-content-padding: 1rem;
|
|
77
|
+
--dile-modal-border-radius: var(--dile-scanner-modal-border-radius, 15px);
|
|
78
|
+
--dile-modal-content-background-color: var(--dile-background-color, #fff);
|
|
79
|
+
--dile-modal-content-text-color: var(--dile-on-background-color, #303030);
|
|
80
|
+
}
|
|
81
|
+
@media (max-width: 600px) {
|
|
82
|
+
:host {
|
|
83
|
+
--dile-modal-width: 100vw;
|
|
84
|
+
--dile-modal-border-radius: 0;
|
|
85
|
+
--dile-modal-height: calc(100dvh - 2rem);
|
|
86
|
+
--dile-modal-max-height: calc(100dvh - 2rem);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
.step {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: 1rem;
|
|
93
|
+
}
|
|
94
|
+
.step[hidden] {
|
|
95
|
+
display: none;
|
|
96
|
+
}
|
|
97
|
+
.scanner {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: 1rem;
|
|
101
|
+
min-height: 100%;
|
|
102
|
+
}
|
|
103
|
+
h2 {
|
|
104
|
+
font-size: 1.3rem;
|
|
105
|
+
font-weight: 300;
|
|
106
|
+
margin: 0;
|
|
107
|
+
}
|
|
108
|
+
p {
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
.message {
|
|
112
|
+
font-size: 0.9rem;
|
|
113
|
+
}
|
|
114
|
+
.error {
|
|
115
|
+
color: var(--dile-alert-error-color, #c00);
|
|
116
|
+
}
|
|
117
|
+
.actions {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-wrap: wrap;
|
|
120
|
+
gap: 0.5rem;
|
|
121
|
+
}
|
|
122
|
+
.footer {
|
|
123
|
+
display: flex;
|
|
124
|
+
justify-content: flex-end;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
gap: 0.5rem;
|
|
127
|
+
}
|
|
128
|
+
@media (max-width: 600px) {
|
|
129
|
+
.footer {
|
|
130
|
+
justify-content: space-between;
|
|
131
|
+
--dile-button-padding-x: 0.6rem;
|
|
132
|
+
--dile-button-font-size: 0.9rem;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
.secondary {
|
|
136
|
+
--dile-button-background-color: var(--dile-scanner-secondary-button-color, #888);
|
|
137
|
+
--dile-button-border-color: var(--dile-scanner-secondary-button-color, #888);
|
|
138
|
+
}
|
|
139
|
+
.file-input {
|
|
140
|
+
display: none;
|
|
141
|
+
}
|
|
142
|
+
.editor {
|
|
143
|
+
position: relative;
|
|
144
|
+
width: 100%;
|
|
145
|
+
height: var(--dile-scanner-editor-height, min(62dvh, 640px));
|
|
146
|
+
border-radius: 0.5rem;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
background-color: var(--dile-scanner-editor-background-color, #1e1e1e);
|
|
149
|
+
}
|
|
150
|
+
@media (max-width: 600px) {
|
|
151
|
+
.editor {
|
|
152
|
+
height: calc(100dvh - 18rem);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.pages {
|
|
156
|
+
display: grid;
|
|
157
|
+
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
158
|
+
gap: 0.75rem;
|
|
159
|
+
}
|
|
160
|
+
.page {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
gap: 0.25rem;
|
|
164
|
+
padding: 0.5rem;
|
|
165
|
+
border-radius: 0.5rem;
|
|
166
|
+
background-color: var(--dile-very-light-color, #f4f4f4);
|
|
167
|
+
}
|
|
168
|
+
.page img {
|
|
169
|
+
width: 100%;
|
|
170
|
+
height: 170px;
|
|
171
|
+
object-fit: contain;
|
|
172
|
+
background-color: #fff;
|
|
173
|
+
border-radius: 0.25rem;
|
|
174
|
+
}
|
|
175
|
+
.single .page {
|
|
176
|
+
max-width: 420px;
|
|
177
|
+
margin: 0 auto;
|
|
178
|
+
width: 100%;
|
|
179
|
+
}
|
|
180
|
+
.single .page img {
|
|
181
|
+
height: min(55dvh, 520px);
|
|
182
|
+
}
|
|
183
|
+
.page-number {
|
|
184
|
+
font-size: 0.85rem;
|
|
185
|
+
text-align: center;
|
|
186
|
+
}
|
|
187
|
+
.page-actions {
|
|
188
|
+
display: flex;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
flex-wrap: wrap;
|
|
191
|
+
gap: 0.25rem;
|
|
192
|
+
--dile-button-padding-y: 0.3rem;
|
|
193
|
+
--dile-button-padding-x: 0.4rem;
|
|
194
|
+
}
|
|
195
|
+
.page-actions .remove {
|
|
196
|
+
--dile-button-background-color: var(--dile-alert-error-color, #c00);
|
|
197
|
+
--dile-button-border-color: var(--dile-alert-error-color, #c00);
|
|
198
|
+
}
|
|
199
|
+
.size {
|
|
200
|
+
font-size: 0.85rem;
|
|
201
|
+
color: var(--dile-secondary-dark-color, #888);
|
|
202
|
+
}
|
|
203
|
+
.busy {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: 0.75rem;
|
|
207
|
+
min-height: 8rem;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
static get properties() {
|
|
214
|
+
return {
|
|
215
|
+
/** Allows several pages in the same document. */
|
|
216
|
+
multipage: { type: Boolean },
|
|
217
|
+
/** "pdf" (one PDF with every page) or "images" (one JPEG per page). */
|
|
218
|
+
output: { type: String },
|
|
219
|
+
/** Maximum size in bytes of the result (0 means no limit). */
|
|
220
|
+
maxBytes: { type: Number, attribute: "max-bytes" },
|
|
221
|
+
/** Longest side in pixels of each scanned page. */
|
|
222
|
+
maxSide: { type: Number, attribute: "max-side" },
|
|
223
|
+
/** JPEG quality of each page, from 0 to 1. */
|
|
224
|
+
quality: { type: Number },
|
|
225
|
+
/** File name without extension. By default, prefix + date and time. */
|
|
226
|
+
filename: { type: String },
|
|
227
|
+
/** Partial object to override the texts (see default-labels.js). */
|
|
228
|
+
labels: { type: Object },
|
|
229
|
+
/**
|
|
230
|
+
* Detects the edges with scanic's neural detector (more robust on hard
|
|
231
|
+
* photos), falling back to the classical one when it finds nothing.
|
|
232
|
+
* Downloads about 2.2 MB with gzip (3.5 MB uncompressed) the first
|
|
233
|
+
* time it is used.
|
|
234
|
+
*/
|
|
235
|
+
mlDetector: { type: Boolean, attribute: "ml-detector" },
|
|
236
|
+
/**
|
|
237
|
+
* Base URL of a self-hosted copy of the "scanic-ml" files (model and
|
|
238
|
+
* ONNX runtime). Empty: they are downloaded from jsDelivr.
|
|
239
|
+
*/
|
|
240
|
+
mlAssetsUrl: { type: String, attribute: "ml-assets-url" },
|
|
241
|
+
_step: { type: String, state: true },
|
|
242
|
+
_pages: { type: Array, state: true },
|
|
243
|
+
_busy: { type: Boolean, state: true },
|
|
244
|
+
_detected: { type: Boolean, state: true },
|
|
245
|
+
_message: { type: String, state: true },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
constructor() {
|
|
250
|
+
super();
|
|
251
|
+
this.multipage = false;
|
|
252
|
+
this.output = "pdf";
|
|
253
|
+
this.maxBytes = 0;
|
|
254
|
+
this.maxSide = 2000;
|
|
255
|
+
this.quality = 0.8;
|
|
256
|
+
this.filename = "";
|
|
257
|
+
this.labels = {};
|
|
258
|
+
this.mlDetector = false;
|
|
259
|
+
this.mlAssetsUrl = "";
|
|
260
|
+
this._step = "capture";
|
|
261
|
+
this._pages = [];
|
|
262
|
+
this._busy = false;
|
|
263
|
+
this._detected = false;
|
|
264
|
+
this._message = "";
|
|
265
|
+
this._queue = [];
|
|
266
|
+
this._adjusting = null;
|
|
267
|
+
this._editor = null;
|
|
268
|
+
this._saved = false;
|
|
269
|
+
// Changes on every reset: an async task started before closing or
|
|
270
|
+
// reopening the scanner checks it to discard its result.
|
|
271
|
+
this._session = 0;
|
|
272
|
+
this._isTouchDevice = window.matchMedia?.("(pointer: coarse)").matches;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
get _labels() {
|
|
276
|
+
return { ...DEFAULT_LABELS, ...this.labels };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get _modal() {
|
|
280
|
+
return this.shadowRoot.getElementById("modal");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Opens the scanner with an empty document. */
|
|
284
|
+
open() {
|
|
285
|
+
this._reset();
|
|
286
|
+
this._saved = false;
|
|
287
|
+
preloadEngine();
|
|
288
|
+
this._modal.open();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Closes the scanner, discarding the pages (dispatches the cancel event). */
|
|
292
|
+
close() {
|
|
293
|
+
this._modal.close();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
disconnectedCallback() {
|
|
297
|
+
super.disconnectedCallback();
|
|
298
|
+
this._reset();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
render() {
|
|
302
|
+
const labels = this._labels;
|
|
303
|
+
return html`
|
|
304
|
+
<dile-modal id="modal" blocking @dile-modal-closed=${this._onModalClosed}>
|
|
305
|
+
<div class="scanner">
|
|
306
|
+
<h2>${labels.title}</h2>
|
|
307
|
+
${this._busy
|
|
308
|
+
? html`<div class="busy">
|
|
309
|
+
<dile-spinner active></dile-spinner>
|
|
310
|
+
<span>${labels.processing}</span>
|
|
311
|
+
</div>`
|
|
312
|
+
: nothing}
|
|
313
|
+
<div class="step" ?hidden=${this._busy}>
|
|
314
|
+
${this._stepTemplate(labels)}
|
|
315
|
+
</div>
|
|
316
|
+
${this._message
|
|
317
|
+
? html`<p class="message error" role="alert">${this._message}</p>`
|
|
318
|
+
: nothing}
|
|
319
|
+
</div>
|
|
320
|
+
</dile-modal>
|
|
321
|
+
<input
|
|
322
|
+
id="camerainput"
|
|
323
|
+
class="file-input"
|
|
324
|
+
type="file"
|
|
325
|
+
accept="image/*"
|
|
326
|
+
capture="environment"
|
|
327
|
+
@change=${this._onFilesSelected}
|
|
328
|
+
/>
|
|
329
|
+
<input
|
|
330
|
+
id="galleryinput"
|
|
331
|
+
class="file-input"
|
|
332
|
+
type="file"
|
|
333
|
+
accept="image/*"
|
|
334
|
+
?multiple=${this.multipage}
|
|
335
|
+
@change=${this._onFilesSelected}
|
|
336
|
+
/>
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_stepTemplate(labels) {
|
|
341
|
+
switch (this._step) {
|
|
342
|
+
case "adjust":
|
|
343
|
+
return this._adjustTemplate(labels);
|
|
344
|
+
case "pages":
|
|
345
|
+
return this._pagesTemplate(labels);
|
|
346
|
+
default:
|
|
347
|
+
return this._captureTemplate(labels);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_captureTemplate(labels) {
|
|
352
|
+
return html`
|
|
353
|
+
<p>${labels.captureHint}</p>
|
|
354
|
+
<div class="actions">
|
|
355
|
+
${this._captureButtons(labels.takePhoto, labels.chooseImage)}
|
|
356
|
+
</div>
|
|
357
|
+
<div class="footer">
|
|
358
|
+
<dile-button class="secondary" icon="lucide.x" @click=${this.close}
|
|
359
|
+
>${labels.cancel}</dile-button
|
|
360
|
+
>
|
|
361
|
+
</div>
|
|
362
|
+
`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
_captureButtons(photoLabel, imageLabel, disabled = false) {
|
|
366
|
+
return html`
|
|
367
|
+
${this._isTouchDevice
|
|
368
|
+
? html`<dile-button
|
|
369
|
+
icon="lucide.camera"
|
|
370
|
+
?disabled=${disabled}
|
|
371
|
+
@click=${this._takePhoto}
|
|
372
|
+
>${photoLabel}</dile-button
|
|
373
|
+
>`
|
|
374
|
+
: nothing}
|
|
375
|
+
<dile-button
|
|
376
|
+
icon="lucide.image"
|
|
377
|
+
?disabled=${disabled}
|
|
378
|
+
@click=${this._chooseImage}
|
|
379
|
+
>${imageLabel}</dile-button
|
|
380
|
+
>
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_adjustTemplate(labels) {
|
|
385
|
+
return html`
|
|
386
|
+
<p class="message">
|
|
387
|
+
${this._detected ? labels.adjustHint : labels.notDetected}
|
|
388
|
+
</p>
|
|
389
|
+
<div id="editor" class="editor"></div>
|
|
390
|
+
<div class="actions">
|
|
391
|
+
<dile-button class="secondary" icon="lucide.undo-2" @click=${this._resetCorners}
|
|
392
|
+
>${labels.reset}</dile-button
|
|
393
|
+
>
|
|
394
|
+
<dile-button
|
|
395
|
+
class="secondary"
|
|
396
|
+
icon="lucide.maximize"
|
|
397
|
+
@click=${this._useFullImage}
|
|
398
|
+
>${labels.useFullImage}</dile-button
|
|
399
|
+
>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="footer">
|
|
402
|
+
<dile-button class="secondary" icon="lucide.x" @click=${this._cancelAdjust}
|
|
403
|
+
>${labels.cancel}</dile-button
|
|
404
|
+
>
|
|
405
|
+
<dile-button icon="lucide.check" @click=${this._acceptAdjust}
|
|
406
|
+
>${labels.accept}</dile-button
|
|
407
|
+
>
|
|
408
|
+
</div>
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_pagesTemplate(labels) {
|
|
413
|
+
const limitReached = this._limitReached;
|
|
414
|
+
return html`
|
|
415
|
+
<div class="${this.multipage ? "" : "single"}">
|
|
416
|
+
<div class="pages">
|
|
417
|
+
${this._pages.map((page, index) => this._pageTemplate(page, index, labels))}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
${this.maxBytes
|
|
421
|
+
? html`<p class="size">
|
|
422
|
+
${this._formatSize(this._pagesSize)} ${labels.sizeOf}
|
|
423
|
+
${this._formatSize(this.maxBytes)}
|
|
424
|
+
</p>`
|
|
425
|
+
: nothing}
|
|
426
|
+
${this.multipage && limitReached
|
|
427
|
+
? html`<p class="message">${labels.sizeLimitReached}</p>`
|
|
428
|
+
: nothing}
|
|
429
|
+
<div class="actions">
|
|
430
|
+
${this.multipage
|
|
431
|
+
? this._captureButtons(labels.addPhoto, labels.addImage, limitReached)
|
|
432
|
+
: this._captureButtons(labels.retakePhoto, labels.replaceImage)}
|
|
433
|
+
</div>
|
|
434
|
+
<div class="footer">
|
|
435
|
+
<dile-button class="secondary" icon="lucide.x" @click=${this.close}
|
|
436
|
+
>${labels.cancel}</dile-button
|
|
437
|
+
>
|
|
438
|
+
<dile-button icon="lucide.check" @click=${this._save}
|
|
439
|
+
>${labels.confirm}</dile-button
|
|
440
|
+
>
|
|
441
|
+
</div>
|
|
442
|
+
`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
_pageTemplate(page, index, labels) {
|
|
446
|
+
const isFirst = index === 0;
|
|
447
|
+
const isLast = index === this._pages.length - 1;
|
|
448
|
+
const showOrder = this.multipage && this._pages.length > 1;
|
|
449
|
+
return html`
|
|
450
|
+
<div class="page">
|
|
451
|
+
<img src=${page.url} alt="${labels.page} ${index + 1}" />
|
|
452
|
+
${this.multipage
|
|
453
|
+
? html`<div class="page-number">${labels.page} ${index + 1}</div>`
|
|
454
|
+
: nothing}
|
|
455
|
+
<div class="page-actions">
|
|
456
|
+
${showOrder
|
|
457
|
+
? html`<dile-button
|
|
458
|
+
icon="lucide.chevron-left"
|
|
459
|
+
label=${labels.moveBefore}
|
|
460
|
+
?disabled=${isFirst}
|
|
461
|
+
@click=${() => this._movePage(index, -1)}
|
|
462
|
+
></dile-button>`
|
|
463
|
+
: nothing}
|
|
464
|
+
<dile-button
|
|
465
|
+
icon="lucide.rotate-cw"
|
|
466
|
+
label=${labels.rotate}
|
|
467
|
+
@click=${() => this._rotatePage(index)}
|
|
468
|
+
></dile-button>
|
|
469
|
+
<dile-button
|
|
470
|
+
icon="lucide.crop"
|
|
471
|
+
label=${labels.adjust}
|
|
472
|
+
@click=${() => this._adjustPage(index)}
|
|
473
|
+
></dile-button>
|
|
474
|
+
${this.multipage
|
|
475
|
+
? html`<dile-button
|
|
476
|
+
class="remove"
|
|
477
|
+
icon="lucide.trash-2"
|
|
478
|
+
label=${labels.remove}
|
|
479
|
+
@click=${() => this._removePage(index)}
|
|
480
|
+
></dile-button>`
|
|
481
|
+
: nothing}
|
|
482
|
+
${showOrder
|
|
483
|
+
? html`<dile-button
|
|
484
|
+
icon="lucide.chevron-right"
|
|
485
|
+
label=${labels.moveAfter}
|
|
486
|
+
?disabled=${isLast}
|
|
487
|
+
@click=${() => this._movePage(index, 1)}
|
|
488
|
+
></dile-button>`
|
|
489
|
+
: nothing}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---- Capture ----
|
|
496
|
+
|
|
497
|
+
_takePhoto() {
|
|
498
|
+
this.shadowRoot.getElementById("camerainput").click();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
_chooseImage() {
|
|
502
|
+
this.shadowRoot.getElementById("galleryinput").click();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
_onFilesSelected(e) {
|
|
506
|
+
const input = e.target;
|
|
507
|
+
const files = [...(input.files ?? [])];
|
|
508
|
+
// Cleared so choosing the same file again also fires "change".
|
|
509
|
+
input.value = "";
|
|
510
|
+
if (files.length === 0) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this._message = "";
|
|
514
|
+
this._queue = this.multipage ? files : files.slice(0, 1);
|
|
515
|
+
this._processNextFile();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The selected images are processed one by one: each one goes through the
|
|
519
|
+
// corner adjustment before the next one.
|
|
520
|
+
async _processNextFile() {
|
|
521
|
+
const file = this._queue.shift();
|
|
522
|
+
if (!file) {
|
|
523
|
+
this._step = this._pages.length ? "pages" : "capture";
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (this.multipage && this._limitReached) {
|
|
527
|
+
this._queue = [];
|
|
528
|
+
this._step = "pages";
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const session = this._session;
|
|
532
|
+
this._busy = true;
|
|
533
|
+
let canvas;
|
|
534
|
+
try {
|
|
535
|
+
canvas = await fileToCanvas(file, SOURCE_MAX_SIDE);
|
|
536
|
+
} catch {
|
|
537
|
+
if (session !== this._session) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this._busy = false;
|
|
541
|
+
this._message = this._labels.readError;
|
|
542
|
+
this._processNextFile();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const [corners, sourceBlob] = await Promise.all([
|
|
547
|
+
detectCorners(canvas, {
|
|
548
|
+
ml: this.mlDetector,
|
|
549
|
+
mlAssetsUrl: this.mlAssetsUrl,
|
|
550
|
+
}).catch(() => null),
|
|
551
|
+
canvasToBlob(canvas, "image/jpeg", SOURCE_QUALITY),
|
|
552
|
+
]);
|
|
553
|
+
if (session !== this._session) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await this._startAdjust({
|
|
557
|
+
canvas,
|
|
558
|
+
sourceBlob,
|
|
559
|
+
corners,
|
|
560
|
+
rotation: 0,
|
|
561
|
+
// Without multipage the new image replaces the current page.
|
|
562
|
+
index: this.multipage || this._pages.length === 0 ? null : 0,
|
|
563
|
+
});
|
|
564
|
+
} catch {
|
|
565
|
+
if (session === this._session) {
|
|
566
|
+
this._failProcessing();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---- Corner adjustment ----
|
|
572
|
+
|
|
573
|
+
async _startAdjust(adjusting) {
|
|
574
|
+
this._destroyEditor();
|
|
575
|
+
this._adjusting = adjusting;
|
|
576
|
+
this._detected = Boolean(adjusting.corners);
|
|
577
|
+
this._step = "adjust";
|
|
578
|
+
this._busy = false;
|
|
579
|
+
const session = this._session;
|
|
580
|
+
await this.updateComplete;
|
|
581
|
+
const editor = await createCornerEditor({
|
|
582
|
+
container: this.shadowRoot.getElementById("editor"),
|
|
583
|
+
image: adjusting.canvas,
|
|
584
|
+
corners: adjusting.corners,
|
|
585
|
+
labels: this._labels,
|
|
586
|
+
});
|
|
587
|
+
if (session !== this._session || this._adjusting !== adjusting) {
|
|
588
|
+
editor.destroy();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
this._editor = editor;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
_resetCorners() {
|
|
595
|
+
this._editor?.reset();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
_useFullImage() {
|
|
599
|
+
const { canvas } = this._adjusting;
|
|
600
|
+
this._editor?.setCorners(fullImageCorners(canvas.width, canvas.height));
|
|
601
|
+
this._acceptAdjust();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
_cancelAdjust() {
|
|
605
|
+
this._destroyEditor();
|
|
606
|
+
this._adjusting = null;
|
|
607
|
+
this._processNextFile();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async _acceptAdjust() {
|
|
611
|
+
if (!this._editor || !this._adjusting) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const corners = this._editor.getCorners();
|
|
615
|
+
const adjusting = this._adjusting;
|
|
616
|
+
const session = this._session;
|
|
617
|
+
this._busy = true;
|
|
618
|
+
try {
|
|
619
|
+
const page = await this._renderPage({ ...adjusting, corners });
|
|
620
|
+
if (session !== this._session) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this._destroyEditor();
|
|
624
|
+
this._adjusting = null;
|
|
625
|
+
this._putPage(page, adjusting.index);
|
|
626
|
+
this._busy = false;
|
|
627
|
+
this._processNextFile();
|
|
628
|
+
} catch {
|
|
629
|
+
if (session === this._session) {
|
|
630
|
+
this._busy = false;
|
|
631
|
+
this._message = this._labels.processError;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
_destroyEditor() {
|
|
637
|
+
this._editor?.destroy();
|
|
638
|
+
this._editor = null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---- Pages ----
|
|
642
|
+
|
|
643
|
+
// Straightens the original with the corners, applies the rotation and
|
|
644
|
+
// encodes the result. The original is kept to be able to adjust or rotate
|
|
645
|
+
// the page again without losing quality.
|
|
646
|
+
async _renderPage(page, settings = this._encodeSettings) {
|
|
647
|
+
const source = page.canvas ?? (await blobToCanvas(page.sourceBlob));
|
|
648
|
+
const extracted = await extractDocument(source, page.corners);
|
|
649
|
+
const rotated = rotateCanvas(extracted, page.rotation);
|
|
650
|
+
const { blob, width, height } = await encodeJpeg(rotated, settings);
|
|
651
|
+
return {
|
|
652
|
+
sourceBlob: page.sourceBlob,
|
|
653
|
+
corners: page.corners,
|
|
654
|
+
rotation: page.rotation,
|
|
655
|
+
blob,
|
|
656
|
+
width,
|
|
657
|
+
height,
|
|
658
|
+
url: URL.createObjectURL(blob),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
get _encodeSettings() {
|
|
663
|
+
return { maxSide: this.maxSide, quality: this.quality };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_putPage(page, index) {
|
|
667
|
+
const pages = [...this._pages];
|
|
668
|
+
if (index === null || index === undefined) {
|
|
669
|
+
pages.push(page);
|
|
670
|
+
} else {
|
|
671
|
+
URL.revokeObjectURL(pages[index].url);
|
|
672
|
+
pages[index] = page;
|
|
673
|
+
}
|
|
674
|
+
this._pages = pages;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async _rotatePage(index) {
|
|
678
|
+
const page = this._pages[index];
|
|
679
|
+
const session = this._session;
|
|
680
|
+
this._busy = true;
|
|
681
|
+
try {
|
|
682
|
+
const rotated = await this._renderPage({
|
|
683
|
+
...page,
|
|
684
|
+
rotation: (page.rotation + 90) % 360,
|
|
685
|
+
});
|
|
686
|
+
if (session !== this._session) {
|
|
687
|
+
URL.revokeObjectURL(rotated.url);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
this._putPage(rotated, index);
|
|
691
|
+
} catch {
|
|
692
|
+
if (session !== this._session) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
this._message = this._labels.processError;
|
|
696
|
+
}
|
|
697
|
+
this._busy = false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async _adjustPage(index) {
|
|
701
|
+
const page = this._pages[index];
|
|
702
|
+
this._message = "";
|
|
703
|
+
const session = this._session;
|
|
704
|
+
this._busy = true;
|
|
705
|
+
try {
|
|
706
|
+
const canvas = await blobToCanvas(page.sourceBlob);
|
|
707
|
+
if (session !== this._session) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
await this._startAdjust({
|
|
711
|
+
canvas,
|
|
712
|
+
sourceBlob: page.sourceBlob,
|
|
713
|
+
corners: page.corners,
|
|
714
|
+
rotation: page.rotation,
|
|
715
|
+
index,
|
|
716
|
+
});
|
|
717
|
+
} catch {
|
|
718
|
+
if (session === this._session) {
|
|
719
|
+
this._failProcessing();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_movePage(index, offset) {
|
|
725
|
+
const target = index + offset;
|
|
726
|
+
if (target < 0 || target >= this._pages.length) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const pages = [...this._pages];
|
|
730
|
+
[pages[index], pages[target]] = [pages[target], pages[index]];
|
|
731
|
+
this._pages = pages;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
_removePage(index) {
|
|
735
|
+
URL.revokeObjectURL(this._pages[index].url);
|
|
736
|
+
this._pages = this._pages.filter((_, i) => i !== index);
|
|
737
|
+
this._message = "";
|
|
738
|
+
if (this._pages.length === 0) {
|
|
739
|
+
this._step = "capture";
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
get _pagesSize() {
|
|
744
|
+
return this._pages.reduce((total, page) => total + page.blob.size, 0);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
get _limitReached() {
|
|
748
|
+
return Boolean(this.maxBytes) && this._pagesSize >= this.maxBytes;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
_failProcessing() {
|
|
752
|
+
this._busy = false;
|
|
753
|
+
this._message = this._labels.processError;
|
|
754
|
+
this._queue = [];
|
|
755
|
+
this._step = this._pages.length ? "pages" : "capture";
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---- Result ----
|
|
759
|
+
|
|
760
|
+
async _save() {
|
|
761
|
+
if (this._pages.length === 0) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
this._message = "";
|
|
765
|
+
const session = this._session;
|
|
766
|
+
this._busy = true;
|
|
767
|
+
try {
|
|
768
|
+
let result = await this._buildResult(this._pages);
|
|
769
|
+
for (const fallback of SIZE_FALLBACKS) {
|
|
770
|
+
if (!this.maxBytes || result.size <= this.maxBytes) {
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
const smallerPages = await Promise.all(
|
|
774
|
+
this._pages.map((page) =>
|
|
775
|
+
this._renderPage(page, {
|
|
776
|
+
maxSide: Math.min(this.maxSide, fallback.maxSide),
|
|
777
|
+
quality: Math.min(this.quality, fallback.quality),
|
|
778
|
+
}),
|
|
779
|
+
),
|
|
780
|
+
);
|
|
781
|
+
smallerPages.forEach((page) => URL.revokeObjectURL(page.url));
|
|
782
|
+
result = await this._buildResult(smallerPages);
|
|
783
|
+
}
|
|
784
|
+
if (session !== this._session) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (this.maxBytes && result.size > this.maxBytes) {
|
|
788
|
+
this._busy = false;
|
|
789
|
+
this._message = this._labels.tooBig;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this._saved = true;
|
|
793
|
+
this.dispatchEvent(
|
|
794
|
+
new CustomEvent("dile-document-scanned", {
|
|
795
|
+
bubbles: true,
|
|
796
|
+
composed: true,
|
|
797
|
+
detail: result.detail,
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
800
|
+
this.close();
|
|
801
|
+
} catch {
|
|
802
|
+
if (session === this._session) {
|
|
803
|
+
this._busy = false;
|
|
804
|
+
this._message = this._labels.processError;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async _buildResult(pages) {
|
|
810
|
+
const name = this._baseFilename();
|
|
811
|
+
const pageFiles = pages.map(
|
|
812
|
+
(page, index) =>
|
|
813
|
+
new File(
|
|
814
|
+
[page.blob],
|
|
815
|
+
pages.length === 1 ? `${name}.jpg` : `${name}-${index + 1}.jpg`,
|
|
816
|
+
{ type: "image/jpeg", lastModified: Date.now() },
|
|
817
|
+
),
|
|
818
|
+
);
|
|
819
|
+
if (this.output === "images") {
|
|
820
|
+
return {
|
|
821
|
+
size: pageFiles.reduce((total, file) => total + file.size, 0),
|
|
822
|
+
detail: { files: pageFiles, pages: pageFiles },
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const file = await buildPdf(pages, `${name}.pdf`);
|
|
826
|
+
return { size: file.size, detail: { file, pages: pageFiles } };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
_baseFilename() {
|
|
830
|
+
if (this.filename) {
|
|
831
|
+
return this.filename;
|
|
832
|
+
}
|
|
833
|
+
const now = new Date();
|
|
834
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
835
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
836
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
837
|
+
return `${this._labels.filenamePrefix}-${date}-${time}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---- Closing ----
|
|
841
|
+
|
|
842
|
+
_onModalClosed(e) {
|
|
843
|
+
// The modal event is internal: the component has its own events.
|
|
844
|
+
e.stopPropagation();
|
|
845
|
+
const saved = this._saved;
|
|
846
|
+
this._reset();
|
|
847
|
+
if (!saved) {
|
|
848
|
+
this.dispatchEvent(
|
|
849
|
+
new CustomEvent("dile-document-scan-cancelled", {
|
|
850
|
+
bubbles: true,
|
|
851
|
+
composed: true,
|
|
852
|
+
}),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
_reset() {
|
|
858
|
+
this._session++;
|
|
859
|
+
this._destroyEditor();
|
|
860
|
+
this._pages.forEach((page) => URL.revokeObjectURL(page.url));
|
|
861
|
+
this._pages = [];
|
|
862
|
+
this._queue = [];
|
|
863
|
+
this._adjusting = null;
|
|
864
|
+
this._busy = false;
|
|
865
|
+
this._message = "";
|
|
866
|
+
this._step = "capture";
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
_formatSize(bytes) {
|
|
870
|
+
if (bytes < 1024 * 1024) {
|
|
871
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
872
|
+
}
|
|
873
|
+
return `${(bytes / (1024 * 1024)).toLocaleString(undefined, {
|
|
874
|
+
maximumFractionDigits: 1,
|
|
875
|
+
})} MB`;
|
|
876
|
+
}
|
|
877
|
+
}
|