@dile/scanner 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,9 +22,9 @@ Read the docs and find demos on the [dile-components site](https://dile-componen
22
22
 
23
23
  ## ML detector files
24
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.
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) in the background the first time the scanner is opened.
26
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:
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. It serves them in a folder with the `scanic-ml` version (`/dile-scanner-ml/0.2.0/`) and exports that URL in a virtual module:
28
28
 
29
29
  ```javascript
30
30
  // vite.config.js
@@ -33,10 +33,36 @@ import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
33
33
  export default { plugins: [scannerMlAssets()] };
34
34
  ```
35
35
 
36
- ```html
37
- <dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
36
+ ```javascript
37
+ import { mlAssetsUrl } from 'virtual:dile-scanner-ml';
38
+
39
+ scanner.mlDetector = true;
40
+ scanner.mlAssetsUrl = mlAssetsUrl;
38
41
  ```
39
42
 
43
+ ## Using it in a PWA
44
+
45
+ The ML files can be cached by the service worker so they are downloaded only once, the first time the scanner is opened. Don't precache them (they would be downloaded when installing the app); add a runtime caching rule instead. With vite-plugin-pwa:
46
+
47
+ ```javascript
48
+ VitePWA({
49
+ workbox: {
50
+ globIgnores: ['**/dile-scanner-ml/**'],
51
+ runtimeCaching: [{
52
+ urlPattern: ({ url }) => url.pathname.includes('/dile-scanner-ml/'),
53
+ handler: 'CacheFirst',
54
+ options: {
55
+ cacheName: 'dile-scanner-ml',
56
+ cacheableResponse: { statuses: [200] },
57
+ expiration: { maxEntries: 3 },
58
+ },
59
+ }],
60
+ },
61
+ })
62
+ ```
63
+
64
+ The versioned folder makes sure an update never mixes cached files of different versions. See the [docs](https://dile-components.com/components/dile-scanner/#pwa) for the details.
65
+
40
66
  ## Maintenance note
41
67
 
42
68
  `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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/scanner",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
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",
@@ -36,5 +36,5 @@
36
36
  "scanic": "1.7.1",
37
37
  "scanic-ml": "0.2.0"
38
38
  },
39
- "gitHead": "978a1d5b553cf39a5614223130f3446ec0bb89f2"
39
+ "gitHead": "e48f49078bcae4a1c3f879c0c0d03e5445d1a535"
40
40
  }
@@ -2,6 +2,8 @@ import { LitElement, html, css, nothing } from "lit";
2
2
  import "@dile/ui/components/modal/modal.js";
3
3
  import "@dile/ui/components/button/button.js";
4
4
  import "@dile/ui/components/spinner/spinner.js";
5
+ import "@dile/ui/components/progress-bar/progress-bar.js";
6
+ import "@dile/iconlib/dile-iconlib.js";
5
7
  import "@dile/iconlib/lucide-icons/camera.js";
6
8
  import "@dile/iconlib/lucide-icons/image.js";
7
9
  import "@dile/iconlib/lucide-icons/check.js";
@@ -50,8 +52,10 @@ const SIZE_FALLBACKS = [
50
52
  // (output="images"). With "multipage" the user can add several pages, reorder
51
53
  // them, rotate them, adjust them again and remove them. With "ml-detector"
52
54
  // 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
+ // downloaded with gzip the first time the scanner opens) instead of the
56
+ // classical one; off by default. A photo waits for that download at most
57
+ // "ml-max-wait" ms, and "ml-fast-connection-only" skips it on slow
58
+ // connections.
55
59
  //
56
60
  // It does not upload anything: when the user saves, it dispatches
57
61
  // "dile-document-scanned" with the files and closes. Closing it without saving
@@ -77,6 +81,14 @@ export class DileScanner extends LitElement {
77
81
  --dile-modal-border-radius: var(--dile-scanner-modal-border-radius, 15px);
78
82
  --dile-modal-content-background-color: var(--dile-background-color, #fff);
79
83
  --dile-modal-content-text-color: var(--dile-on-background-color, #303030);
84
+ /* Grays mixed from the theme background and text colors, so the
85
+ scanner works with light and dark themes without hardcoded grays. */
86
+ --_fg: var(--dile-on-background-color, #303030);
87
+ --_bg: var(--dile-background-color, #fff);
88
+ --_surface: color-mix(in srgb, var(--_fg) 6%, var(--_bg));
89
+ --_surface-strong: color-mix(in srgb, var(--_fg) 12%, var(--_bg));
90
+ --_border: color-mix(in srgb, var(--_fg) 15%, var(--_bg));
91
+ --_muted: color-mix(in srgb, var(--_fg) 68%, var(--_bg));
80
92
  }
81
93
  @media (max-width: 600px) {
82
94
  :host {
@@ -127,14 +139,39 @@ export class DileScanner extends LitElement {
127
139
  }
128
140
  @media (max-width: 600px) {
129
141
  .footer {
130
- justify-content: space-between;
142
+ display: grid;
143
+ grid-auto-columns: 1fr;
144
+ grid-auto-flow: column;
131
145
  --dile-button-padding-x: 0.6rem;
132
146
  --dile-button-font-size: 0.9rem;
133
147
  }
148
+ .footer dile-button {
149
+ display: grid;
150
+ }
134
151
  }
152
+ /* Secondary buttons set their own text color: inheriting
153
+ --dile-on-primary-color can leave text without contrast on their
154
+ background. By default they use the theme background and text colors,
155
+ so they also work with dark themes. Hover keeps the same
156
+ colors so a theme hover color does not ruin the contrast either. */
135
157
  .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);
158
+ --dile-button-background-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
159
+ --dile-button-border-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
160
+ --dile-button-text-color: var(--dile-scanner-secondary-button-text-color, var(--_fg));
161
+ --dile-button-hover-background-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
162
+ --dile-button-hover-border-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
163
+ --dile-button-hover-text-color: var(--dile-scanner-secondary-button-text-color, var(--_fg));
164
+ }
165
+ /* Outlined buttons to add pages, lighter than the main action. */
166
+ .outline {
167
+ --dile-button-background-color: transparent;
168
+ --dile-button-hover-background-color: transparent;
169
+ --dile-button-border-color: var(--dile-scanner-outline-button-color, var(--_fg));
170
+ --dile-button-hover-border-color: var(--dile-scanner-outline-button-color, var(--_fg));
171
+ --dile-button-text-color: var(--dile-scanner-outline-button-color, var(--_fg));
172
+ --dile-button-hover-text-color: var(--dile-scanner-outline-button-color, var(--_fg));
173
+ --dile-button-border-width: 1px;
174
+ --dile-button-font-weight: normal;
138
175
  }
139
176
  .file-input {
140
177
  display: none;
@@ -152,53 +189,137 @@ export class DileScanner extends LitElement {
152
189
  height: calc(100dvh - 18rem);
153
190
  }
154
191
  }
192
+ .subtitle {
193
+ margin-top: -0.75rem;
194
+ font-size: 0.9rem;
195
+ color: var(--dile-scanner-muted-text-color, var(--_muted));
196
+ }
197
+ /* Pages in a horizontal scroll area with snap: on a phone each card
198
+ takes most of the width and the next one peeks out; on wider screens
199
+ several cards fit side by side. */
155
200
  .pages {
156
- display: grid;
157
- grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
201
+ display: flex;
158
202
  gap: 0.75rem;
203
+ overflow-x: auto;
204
+ scroll-snap-type: x mandatory;
205
+ padding: 0.25rem 0.25rem 0.75rem;
206
+ margin: 0 -0.25rem;
207
+ scrollbar-width: thin;
159
208
  }
160
209
  .page {
210
+ flex: 0 0 var(--dile-scanner-page-width, min(78%, 250px));
211
+ scroll-snap-align: center;
161
212
  display: flex;
162
213
  flex-direction: column;
163
- gap: 0.25rem;
214
+ gap: 0.5rem;
164
215
  padding: 0.5rem;
165
- border-radius: 0.5rem;
166
- background-color: var(--dile-very-light-color, #f4f4f4);
216
+ border-radius: 0.75rem;
217
+ background-color: var(--dile-scanner-page-background-color, var(--_bg));
218
+ border: 1px solid var(--dile-scanner-page-border-color, var(--_border));
219
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
220
+ }
221
+ .page-header .page-number {
222
+ color: var(--_fg);
167
223
  }
168
224
  .page img {
169
225
  width: 100%;
170
- height: 170px;
226
+ height: 200px;
171
227
  object-fit: contain;
172
- background-color: #fff;
173
- border-radius: 0.25rem;
228
+ padding: 0.5rem;
229
+ background-color: var(--dile-scanner-page-image-background-color, var(--_surface-strong));
230
+ border-radius: 0.5rem;
231
+ }
232
+ .single .pages {
233
+ justify-content: center;
174
234
  }
175
235
  .single .page {
176
- max-width: 420px;
177
- margin: 0 auto;
178
- width: 100%;
236
+ flex-basis: min(100%, 420px);
179
237
  }
180
238
  .single .page img {
181
239
  height: min(55dvh, 520px);
182
240
  }
241
+ .page-header {
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: space-between;
245
+ gap: 0.25rem;
246
+ padding: 0.25rem;
247
+ border-radius: 0.5rem;
248
+ background-color: var(--dile-scanner-page-header-background-color, var(--_surface));
249
+ --dile-button-border-radius: 0.4rem;
250
+ --dile-button-border-width: 0;
251
+ --dile-button-padding-y: 0.3rem;
252
+ --dile-button-padding-x: 0.3rem;
253
+ --dile-button-background-color: var(--dile-scanner-move-button-color, #5f6368);
254
+ --dile-button-hover-background-color: var(--dile-scanner-move-button-color, #5f6368);
255
+ --dile-button-text-color: #fff;
256
+ --dile-button-hover-text-color: #fff;
257
+ --dile-button-disabled-background-color: transparent;
258
+ --dile-button-disabled-text-color: var(--_border);
259
+ }
183
260
  .page-number {
184
- font-size: 0.85rem;
261
+ flex: 1;
262
+ font-size: 0.9rem;
185
263
  text-align: center;
186
264
  }
187
265
  .page-actions {
188
- display: flex;
189
- justify-content: center;
190
- flex-wrap: wrap;
266
+ display: grid;
267
+ grid-auto-columns: 1fr;
268
+ grid-auto-flow: column;
191
269
  gap: 0.25rem;
192
- --dile-button-padding-y: 0.3rem;
193
- --dile-button-padding-x: 0.4rem;
194
270
  }
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);
271
+ .page-action {
272
+ display: flex;
273
+ flex-direction: column;
274
+ align-items: center;
275
+ gap: 0.2rem;
276
+ padding: 0.35rem 0.2rem;
277
+ border: none;
278
+ border-radius: 0.4rem;
279
+ background: none;
280
+ font: inherit;
281
+ font-size: 0.7rem;
282
+ text-transform: uppercase;
283
+ letter-spacing: 0.02em;
284
+ cursor: pointer;
285
+ color: var(--dile-scanner-page-action-color, var(--_fg));
286
+ --dile-icon-color: currentColor;
287
+ --dile-icon-size: 22px;
288
+ }
289
+ .page-action:hover {
290
+ background-color: var(--dile-scanner-page-header-background-color, var(--_surface));
291
+ }
292
+ .page-action:focus-visible {
293
+ outline: 2px solid var(--dile-button-ring-color, #12c9e9);
294
+ }
295
+ .page-action.remove {
296
+ color: var(--dile-scanner-remove-button-color, var(--dile-alert-error-color, #c00));
297
+ font-weight: bold;
198
298
  }
199
299
  .size {
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 0.4rem;
200
303
  font-size: 0.85rem;
201
- color: var(--dile-secondary-dark-color, #888);
304
+ --dile-progress-background: var(--dile-scanner-size-track-color, var(--_surface-strong));
305
+ --dile-progress-bar-background: var(--dile-scanner-size-bar-color, var(--dile-scanner-accent-color, var(--_fg)));
306
+ --dile-progress-bar-min-height: 6px;
307
+ --dile-progress-bar-percentage-font-size: 0;
308
+ --dile-progres-bar-percentage-padding-top: 0;
309
+ --dile-progres-bar-percentage-padding-right: 0;
310
+ --dile-progres-bar-percentage-padding-bottom: 0;
311
+ --dile-progres-bar-percentage-padding-left: 0;
312
+ }
313
+ .size.full {
314
+ --dile-progress-bar-background: var(--dile-alert-error-color, #c00);
315
+ }
316
+ .size-row {
317
+ display: flex;
318
+ justify-content: space-between;
319
+ gap: 0.5rem;
320
+ }
321
+ .size-value {
322
+ color: var(--dile-scanner-muted-text-color, var(--_muted));
202
323
  }
203
324
  .busy {
204
325
  display: flex;
@@ -230,7 +351,7 @@ export class DileScanner extends LitElement {
230
351
  * Detects the edges with scanic's neural detector (more robust on hard
231
352
  * photos), falling back to the classical one when it finds nothing.
232
353
  * Downloads about 2.2 MB with gzip (3.5 MB uncompressed) the first
233
- * time it is used.
354
+ * time the scanner is opened.
234
355
  */
235
356
  mlDetector: { type: Boolean, attribute: "ml-detector" },
236
357
  /**
@@ -238,6 +359,19 @@ export class DileScanner extends LitElement {
238
359
  * ONNX runtime). Empty: they are downloaded from jsDelivr.
239
360
  */
240
361
  mlAssetsUrl: { type: String, attribute: "ml-assets-url" },
362
+ /**
363
+ * Maximum time in milliseconds a photo waits for the ML detector while
364
+ * it downloads (0 means no limit). When it is not ready, the classical
365
+ * detector is used for that photo and the download goes on in the
366
+ * background.
367
+ */
368
+ mlMaxWait: { type: Number, attribute: "ml-max-wait" },
369
+ /**
370
+ * Does not download the ML detector when the browser reports a slow
371
+ * connection (2G, 3G or data saver), unless it is already in the cache
372
+ * (Cache Storage). The classical detector is used instead.
373
+ */
374
+ mlFastConnectionOnly: { type: Boolean, attribute: "ml-fast-connection-only" },
241
375
  _step: { type: String, state: true },
242
376
  _pages: { type: Array, state: true },
243
377
  _busy: { type: Boolean, state: true },
@@ -257,6 +391,8 @@ export class DileScanner extends LitElement {
257
391
  this.labels = {};
258
392
  this.mlDetector = false;
259
393
  this.mlAssetsUrl = "";
394
+ this.mlMaxWait = 5000;
395
+ this.mlFastConnectionOnly = false;
260
396
  this._step = "capture";
261
397
  this._pages = [];
262
398
  this._busy = false;
@@ -266,6 +402,7 @@ export class DileScanner extends LitElement {
266
402
  this._adjusting = null;
267
403
  this._editor = null;
268
404
  this._saved = false;
405
+ this._scrollToPage = null;
269
406
  // Changes on every reset: an async task started before closing or
270
407
  // reopening the scanner checks it to discard its result.
271
408
  this._session = 0;
@@ -276,6 +413,15 @@ export class DileScanner extends LitElement {
276
413
  return { ...DEFAULT_LABELS, ...this.labels };
277
414
  }
278
415
 
416
+ get _engineOptions() {
417
+ return {
418
+ ml: this.mlDetector,
419
+ mlAssetsUrl: this.mlAssetsUrl,
420
+ mlMaxWait: this.mlMaxWait,
421
+ mlFastConnectionOnly: this.mlFastConnectionOnly,
422
+ };
423
+ }
424
+
279
425
  get _modal() {
280
426
  return this.shadowRoot.getElementById("modal");
281
427
  }
@@ -284,7 +430,7 @@ export class DileScanner extends LitElement {
284
430
  open() {
285
431
  this._reset();
286
432
  this._saved = false;
287
- preloadEngine();
433
+ preloadEngine(this._engineOptions);
288
434
  this._modal.open();
289
435
  }
290
436
 
@@ -293,6 +439,18 @@ export class DileScanner extends LitElement {
293
439
  this._modal.close();
294
440
  }
295
441
 
442
+ updated() {
443
+ // Brings a new or moved page into view in the horizontal page list. It
444
+ // waits while the step is hidden (busy), because a hidden element can't
445
+ // be scrolled.
446
+ if (this._scrollToPage === null || this._busy || this._step !== "pages") {
447
+ return;
448
+ }
449
+ const page = this.shadowRoot.querySelector(`.page[data-index="${this._scrollToPage}"]`);
450
+ this._scrollToPage = null;
451
+ page?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
452
+ }
453
+
296
454
  disconnectedCallback() {
297
455
  super.disconnectedCallback();
298
456
  this._reset();
@@ -304,6 +462,12 @@ export class DileScanner extends LitElement {
304
462
  <dile-modal id="modal" blocking @dile-modal-closed=${this._onModalClosed}>
305
463
  <div class="scanner">
306
464
  <h2>${labels.title}</h2>
465
+ ${this._step === "pages" && this.multipage && !this._busy
466
+ ? html`<p class="subtitle">
467
+ ${this._pages.length}
468
+ ${this._pages.length === 1 ? labels.pageScanned : labels.pagesScanned}
469
+ </p>`
470
+ : nothing}
307
471
  ${this._busy
308
472
  ? html`<div class="busy">
309
473
  <dile-spinner active></dile-spinner>
@@ -362,10 +526,11 @@ export class DileScanner extends LitElement {
362
526
  `;
363
527
  }
364
528
 
365
- _captureButtons(photoLabel, imageLabel, disabled = false) {
529
+ _captureButtons(photoLabel, imageLabel, disabled = false, className = "") {
366
530
  return html`
367
531
  ${this._isTouchDevice
368
532
  ? html`<dile-button
533
+ class=${className}
369
534
  icon="lucide.camera"
370
535
  ?disabled=${disabled}
371
536
  @click=${this._takePhoto}
@@ -373,6 +538,7 @@ export class DileScanner extends LitElement {
373
538
  >`
374
539
  : nothing}
375
540
  <dile-button
541
+ class=${className}
376
542
  icon="lucide.image"
377
543
  ?disabled=${disabled}
378
544
  @click=${this._chooseImage}
@@ -411,87 +577,100 @@ export class DileScanner extends LitElement {
411
577
 
412
578
  _pagesTemplate(labels) {
413
579
  const limitReached = this._limitReached;
580
+ const count = this._pages.length;
414
581
  return html`
415
582
  <div class="${this.multipage ? "" : "single"}">
416
583
  <div class="pages">
417
584
  ${this._pages.map((page, index) => this._pageTemplate(page, index, labels))}
418
585
  </div>
419
586
  </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}
587
+ ${this.maxBytes ? this._sizeTemplate(labels, limitReached) : nothing}
426
588
  ${this.multipage && limitReached
427
589
  ? html`<p class="message">${labels.sizeLimitReached}</p>`
428
590
  : nothing}
429
591
  <div class="actions">
430
592
  ${this.multipage
431
- ? this._captureButtons(labels.addPhoto, labels.addImage, limitReached)
432
- : this._captureButtons(labels.retakePhoto, labels.replaceImage)}
593
+ ? this._captureButtons(labels.addPhoto, labels.addImage, limitReached, "outline")
594
+ : this._captureButtons(labels.retakePhoto, labels.replaceImage, false, "outline")}
433
595
  </div>
434
596
  <div class="footer">
435
597
  <dile-button class="secondary" icon="lucide.x" @click=${this.close}
436
598
  >${labels.cancel}</dile-button
437
599
  >
438
600
  <dile-button icon="lucide.check" @click=${this._save}
439
- >${labels.confirm}</dile-button
601
+ >${labels.confirm}${this.multipage ? ` (${count})` : ""}</dile-button
440
602
  >
441
603
  </div>
442
604
  `;
443
605
  }
444
606
 
607
+ _sizeTemplate(labels, limitReached) {
608
+ const percent = Math.min(100, Math.round((this._pagesSize / this.maxBytes) * 100));
609
+ return html`
610
+ <div class="size ${limitReached ? "full" : ""}">
611
+ <div class="size-row">
612
+ <span>${labels.documentSize}</span>
613
+ <span class="size-value"
614
+ >${this._formatSize(this._pagesSize)} ${labels.sizeOf}
615
+ ${this._formatSize(this.maxBytes)}</span
616
+ >
617
+ </div>
618
+ <dile-progress-bar value=${percent} aria-hidden="true"></dile-progress-bar>
619
+ </div>
620
+ `;
621
+ }
622
+
445
623
  _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;
624
+ const count = this._pages.length;
625
+ const showOrder = this.multipage && count > 1;
449
626
  return html`
450
- <div class="page">
451
- <img src=${page.url} alt="${labels.page} ${index + 1}" />
627
+ <div class="page" data-index=${index}>
452
628
  ${this.multipage
453
- ? html`<div class="page-number">${labels.page} ${index + 1}</div>`
629
+ ? html`<div class="page-header">
630
+ ${showOrder
631
+ ? html`<dile-button
632
+ icon="lucide.chevron-left"
633
+ label=${labels.moveBefore}
634
+ title=${labels.moveBefore}
635
+ ?disabled=${index === 0}
636
+ @click=${() => this._movePage(index, -1)}
637
+ ></dile-button>`
638
+ : nothing}
639
+ <span class="page-number"
640
+ >${labels.page} ${index + 1} ${labels.pageOf} ${count}</span
641
+ >
642
+ ${showOrder
643
+ ? html`<dile-button
644
+ icon="lucide.chevron-right"
645
+ label=${labels.moveAfter}
646
+ title=${labels.moveAfter}
647
+ ?disabled=${index === count - 1}
648
+ @click=${() => this._movePage(index, 1)}
649
+ ></dile-button>`
650
+ : nothing}
651
+ </div>`
454
652
  : nothing}
653
+ <img src=${page.url} alt="${labels.page} ${index + 1}" />
455
654
  <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>
655
+ ${this._pageAction("lucide.rotate-cw", labels.rotate, () => this._rotatePage(index))}
656
+ ${this._pageAction("lucide.crop", labels.adjust, () => this._adjustPage(index))}
474
657
  ${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>`
658
+ ? this._pageAction("lucide.trash-2", labels.remove, () => this._removePage(index), "remove")
489
659
  : nothing}
490
660
  </div>
491
661
  </div>
492
662
  `;
493
663
  }
494
664
 
665
+ _pageAction(icon, label, onClick, className = "") {
666
+ return html`
667
+ <button type="button" class="page-action ${className}" data-icon=${icon} @click=${onClick}>
668
+ <dile-iconlib icon=${icon} aria-hidden="true"></dile-iconlib>
669
+ <span>${label}</span>
670
+ </button>
671
+ `;
672
+ }
673
+
495
674
  // ---- Capture ----
496
675
 
497
676
  _takePhoto() {
@@ -544,10 +723,7 @@ export class DileScanner extends LitElement {
544
723
  }
545
724
  try {
546
725
  const [corners, sourceBlob] = await Promise.all([
547
- detectCorners(canvas, {
548
- ml: this.mlDetector,
549
- mlAssetsUrl: this.mlAssetsUrl,
550
- }).catch(() => null),
726
+ detectCorners(canvas, this._engineOptions).catch(() => null),
551
727
  canvasToBlob(canvas, "image/jpeg", SOURCE_QUALITY),
552
728
  ]);
553
729
  if (session !== this._session) {
@@ -667,6 +843,7 @@ export class DileScanner extends LitElement {
667
843
  const pages = [...this._pages];
668
844
  if (index === null || index === undefined) {
669
845
  pages.push(page);
846
+ this._scrollToPage = pages.length - 1;
670
847
  } else {
671
848
  URL.revokeObjectURL(pages[index].url);
672
849
  pages[index] = page;
@@ -729,6 +906,7 @@ export class DileScanner extends LitElement {
729
906
  const pages = [...this._pages];
730
907
  [pages[index], pages[target]] = [pages[target], pages[index]];
731
908
  this._pages = pages;
909
+ this._scrollToPage = target;
732
910
  }
733
911
 
734
912
  _removePage(index) {
@@ -19,11 +19,15 @@ export const DEFAULT_LABELS = {
19
19
  cancel: "Cancel",
20
20
  confirm: "Save document",
21
21
  page: "Page",
22
+ pageOf: "of",
23
+ pageScanned: "page scanned",
24
+ pagesScanned: "pages scanned",
25
+ documentSize: "Document size",
22
26
  rotate: "Rotate",
23
- adjust: "Adjust edges",
27
+ adjust: "Crop",
24
28
  moveBefore: "Move before",
25
29
  moveAfter: "Move after",
26
- remove: "Remove page",
30
+ remove: "Delete",
27
31
  processing: "Processing…",
28
32
  readError:
29
33
  "The image could not be read. Try with another image (JPG, PNG or WebP).",
@@ -12,12 +12,121 @@ function loadScanic() {
12
12
  return scanicModule;
13
13
  }
14
14
 
15
+ // Base URL of the "scanic-ml" files when no mlAssetsUrl is given. scanic uses
16
+ // the same one internally; it is repeated here to look for the files in the
17
+ // cache (see isMlCached). scanner-ml.spec.js checks that its version matches
18
+ // the installed "scanic-ml".
19
+ export const CDN_ML_ASSETS_URL = "https://cdn.jsdelivr.net/npm/scanic-ml@0.2.0/dist/";
20
+
21
+ // The two big files of the ML detector (the .mjs loader is tiny).
22
+ const ML_CACHE_CHECK_FILES = ["doccornernet_lean.ort", "ort-wasm-simd-threaded.wasm"];
23
+
24
+ // Connections where downloading about 2.2 MB takes too long (see
25
+ // isSlowConnection).
26
+ const SLOW_CONNECTION_TYPES = ["slow-2g", "2g", "3g"];
27
+
28
+ // scanic aborts the download of the model after 30 s by default. The download
29
+ // goes on in the background even when a photo stops waiting for it (see
30
+ // mlMaxWait), so it gets a much longer limit: on a slow connection it would
31
+ // never finish (nor reach the cache) with 30 s, and with no limit at all a
32
+ // stalled connection would keep it pending until the page is reloaded.
33
+ const ML_MODEL_FETCH_TIMEOUT_MS = 5 * 60 * 1000;
34
+
35
+ // Loading of the ML detector: { key, promise }. The promise resolves to true
36
+ // when it is ready and to false when it fails or is skipped (slow connection),
37
+ // and never rejects. It is forgotten when it does not end ready, so the next
38
+ // open() or photo decides again (e.g. the network is back or got faster).
39
+ let mlLoad = null;
40
+
15
41
  // Starts downloading scanic in the background (e.g. when the scanner opens),
16
- // so it is ready by the time the first photo arrives.
17
- export function preloadEngine() {
42
+ // so it is ready by the time the first photo arrives. With "ml" it also
43
+ // prepares the neural detector (see loadMl), so the first photo does not wait
44
+ // for about 2.2 MB. Calling it on every open() downloads nothing again.
45
+ export function preloadEngine({ ml = false, mlAssetsUrl = "", mlFastConnectionOnly = false } = {}) {
18
46
  loadScanic().catch(() => {
19
47
  // Ignored here: the error shows up again when the engine is really used.
20
48
  });
49
+ if (ml) {
50
+ loadMl({ mlAssetsUrl, mlFastConnectionOnly });
51
+ }
52
+ }
53
+
54
+ // Downloads the ML detector files and creates its session. scanic keeps the
55
+ // session cached by URL, so detectCorners() reuses it. With
56
+ // "mlFastConnectionOnly" nothing is downloaded on a slow connection, unless the
57
+ // files are already in the cache.
58
+ function loadMl({ mlAssetsUrl, mlFastConnectionOnly }) {
59
+ const key = mlAssetsUrl;
60
+ if (mlLoad?.key === key) {
61
+ return mlLoad.promise;
62
+ }
63
+ const current = {
64
+ key,
65
+ promise: (async () => {
66
+ if (mlFastConnectionOnly && isSlowConnection() && !(await isMlCached(mlAssetsUrl))) {
67
+ return false;
68
+ }
69
+ const { Scanner } = await loadScanic();
70
+ await new Scanner({ detector: "ml", ml: mlOptions(mlAssetsUrl) }).initialize();
71
+ return true;
72
+ })().catch(() => false),
73
+ };
74
+ mlLoad = current;
75
+ current.promise.then((ready) => {
76
+ if (!ready && mlLoad === current) {
77
+ mlLoad = null;
78
+ }
79
+ });
80
+ return current.promise;
81
+ }
82
+
83
+ // True when the browser says the connection is slow or the user asked to save
84
+ // data. Only Chromium browsers have navigator.connection: elsewhere (Safari,
85
+ // Firefox) the connection is unknown and this returns false.
86
+ export function isSlowConnection() {
87
+ const connection = globalThis.navigator?.connection;
88
+ if (!connection) {
89
+ return false;
90
+ }
91
+ return connection.saveData === true || SLOW_CONNECTION_TYPES.includes(connection.effectiveType);
92
+ }
93
+
94
+ // True when the ML files are in the Cache Storage (e.g. cached by the service
95
+ // worker of a PWA). The HTTP cache of the browser can't be checked.
96
+ export async function isMlCached(mlAssetsUrl = "") {
97
+ if (!globalThis.caches) {
98
+ return false;
99
+ }
100
+ try {
101
+ const base = new URL(mlAssetsUrl || CDN_ML_ASSETS_URL, document.baseURI);
102
+ const folder = base.href.endsWith("/") ? base.href : `${base.href}/`;
103
+ const matches = await Promise.all(
104
+ ML_CACHE_CHECK_FILES.map((file) => caches.match(new URL(file, folder).href)),
105
+ );
106
+ return matches.every(Boolean);
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function mlOptions(mlAssetsUrl) {
113
+ return {
114
+ ...(mlAssetsUrl ? { assetBaseUrl: mlAssetsUrl } : {}),
115
+ modelFetchTimeoutMs: ML_MODEL_FETCH_TIMEOUT_MS,
116
+ };
117
+ }
118
+
119
+ // Resolves to the value of the promise, or to false after "ms" milliseconds
120
+ // (0 means no limit). The promise itself goes on.
121
+ function withTimeout(promise, ms) {
122
+ if (!ms || ms <= 0) {
123
+ return promise;
124
+ }
125
+ let timer;
126
+ const timeout = new Promise((resolve) => {
127
+ timer = setTimeout(() => resolve(false), ms);
128
+ });
129
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
21
130
  }
22
131
 
23
132
  // Returns the corners of the document or null when none is found.
@@ -28,19 +137,27 @@ export function preloadEngine() {
28
137
  // not as a retry, because on hard photos the classical detector does not
29
138
  // always fail: it can return a wrong shape (e.g. a line of text) as if it
30
139
  // were the document. The neural detector downloads about 2.2 MB with gzip
31
- // (3.5 MB uncompressed: model + ONNX runtime) the first time, from jsDelivr
32
- // unless mlAssetsUrl points to a self-hosted copy of the "scanic-ml" package
33
- // files. When it finds nothing or fails (e.g. no network), the classical
34
- // detector is used.
35
- export async function detectCorners(canvas, { ml = false, mlAssetsUrl = "" } = {}) {
140
+ // (3.5 MB uncompressed: model + ONNX runtime) the first time (when the
141
+ // scanner opens, see preloadEngine), from jsDelivr unless mlAssetsUrl points
142
+ // to a self-hosted copy of the "scanic-ml" package files.
143
+ //
144
+ // The photo waits for the ML detector at most "mlMaxWait" milliseconds (0
145
+ // means no limit). When it is not ready by then, it was skipped (see
146
+ // mlFastConnectionOnly), it finds nothing or fails (e.g. no network), the
147
+ // classical detector is used. The download goes on in the background, so a
148
+ // later photo or the next open() can use it.
149
+ export async function detectCorners(
150
+ canvas,
151
+ { ml = false, mlAssetsUrl = "", mlMaxWait = 0, mlFastConnectionOnly = false } = {},
152
+ ) {
36
153
  const { Scanner } = await loadScanic();
37
154
  scanner ??= new Scanner();
38
- if (ml) {
155
+ if (ml && (await withTimeout(loadMl({ mlAssetsUrl, mlFastConnectionOnly }), mlMaxWait))) {
39
156
  try {
40
157
  const mlResult = await scanner.scan(canvas, {
41
158
  mode: "detect",
42
159
  detector: "ml",
43
- ml: mlAssetsUrl ? { assetBaseUrl: mlAssetsUrl } : {},
160
+ ml: mlOptions(mlAssetsUrl),
44
161
  });
45
162
  if (mlResult.success) {
46
163
  return mlResult.corners;
@@ -135,7 +135,10 @@ export async function openScanner(attrs) {
135
135
  export const query = (el, selector) => el.shadowRoot.querySelector(selector);
136
136
  export const queryAll = (el, selector) => [...el.shadowRoot.querySelectorAll(selector)];
137
137
  export const button = (el, icon) => query(el, `.step:not([hidden]) dile-button[icon="lucide.${icon}"]`);
138
- export const pageButtons = (el, icon) => queryAll(el, `.page dile-button[icon="lucide.${icon}"]`);
138
+ // Buttons of the page cards: the move arrows (dile-button in the header) and
139
+ // the actions (native buttons with data-icon).
140
+ export const pageButtons = (el, icon) =>
141
+ queryAll(el, `.page dile-button[icon="lucide.${icon}"], .page button[data-icon="lucide.${icon}"]`);
139
142
  export const pages = (el) => queryAll(el, '.page');
140
143
  export const errorMessage = (el) => query(el, '.message.error')?.textContent.trim();
141
144
 
package/vite-plugin.js CHANGED
@@ -6,11 +6,19 @@
6
6
  // import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
7
7
  // export default { plugins: [scannerMlAssets()] };
8
8
  //
9
- // <dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
9
+ // // app code
10
+ // import { mlAssetsUrl } from 'virtual:dile-scanner-ml';
11
+ // scanner.mlAssetsUrl = mlAssetsUrl; // "/dile-scanner-ml/0.2.0/"
10
12
  //
11
13
  // The files are served by the dev server and copied to the build with their
12
14
  // original names (scanic expects them together in one folder). They are only
13
15
  // downloaded by the browser when the ML detector is actually used.
16
+ //
17
+ // The default folder includes the scanic-ml version, so updating
18
+ // @dile/scanner changes the URL of the files. That way a cache (HTTP or a PWA
19
+ // service worker) never mixes the model of one version with the runtime of
20
+ // another. The "virtual:dile-scanner-ml" module exports the current URL, so
21
+ // the app does not have to write the version by hand.
14
22
  import { createRequire } from "node:module";
15
23
  import { readFileSync } from "node:fs";
16
24
  import { dirname, extname, join } from "node:path";
@@ -21,24 +29,54 @@ export const ML_ASSET_FILES = [
21
29
  "ort-wasm-simd-threaded.wasm",
22
30
  ];
23
31
 
32
+ export const ML_VIRTUAL_MODULE = "virtual:dile-scanner-ml";
33
+ const RESOLVED_VIRTUAL_MODULE = `\0${ML_VIRTUAL_MODULE}`;
34
+
24
35
  const CONTENT_TYPES = {
25
36
  ".ort": "application/octet-stream",
26
37
  ".mjs": "text/javascript",
27
38
  ".wasm": "application/wasm",
28
39
  };
29
40
 
41
+ function mlPackageJson() {
42
+ const require = createRequire(import.meta.url);
43
+ return require.resolve("scanic-ml/package.json");
44
+ }
45
+
30
46
  // Folder of the "scanic-ml" files, resolved from this package so it is the
31
47
  // version pinned by @dile/scanner.
32
48
  export function mlAssetsDir() {
33
- const require = createRequire(import.meta.url);
34
- return join(dirname(require.resolve("scanic-ml/package.json")), "dist");
49
+ return join(dirname(mlPackageJson()), "dist");
35
50
  }
36
51
 
37
- export function scannerMlAssets({ path = "dile-scanner-ml" } = {}) {
52
+ // Version of the "scanic-ml" files (e.g. "0.2.0").
53
+ export function mlAssetsVersion() {
54
+ return JSON.parse(readFileSync(mlPackageJson(), "utf8")).version;
55
+ }
56
+
57
+ export function scannerMlAssets({
58
+ path = `dile-scanner-ml/${mlAssetsVersion()}`,
59
+ } = {}) {
38
60
  const folder = path.replace(/^\/+|\/+$/g, "");
39
61
  const assetsDir = mlAssetsDir();
62
+ let base = "/";
40
63
  return {
41
64
  name: "dile-scanner-ml-assets",
65
+ configResolved(config) {
66
+ base = config.base.endsWith("/") ? config.base : `${config.base}/`;
67
+ },
68
+ resolveId(id) {
69
+ return id === ML_VIRTUAL_MODULE ? RESOLVED_VIRTUAL_MODULE : null;
70
+ },
71
+ load(id) {
72
+ if (id !== RESOLVED_VIRTUAL_MODULE) {
73
+ return null;
74
+ }
75
+ return [
76
+ `export const mlAssetsUrl = ${JSON.stringify(`${base}${folder}/`)};`,
77
+ `export const mlAssetsVersion = ${JSON.stringify(mlAssetsVersion())};`,
78
+ ].join("\n");
79
+ },
42
80
  configureServer(server) {
43
81
  server.middlewares.use((req, res, next) => {
44
82
  const prefix = `${server.config.base}${folder}/`;