@dile/scanner 0.0.0-stage → 0.0.1

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