@dile/scanner 0.0.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/scanner",
3
- "version": "0.0.9",
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": "2aab20e22c7c9ea537e67ca7bacd392ee6936885"
39
+ "gitHead": "e48f49078bcae4a1c3f879c0c0d03e5445d1a535"
40
40
  }
@@ -53,7 +53,9 @@ const SIZE_FALLBACKS = [
53
53
  // them, rotate them, adjust them again and remove them. With "ml-detector"
54
54
  // the edges are detected with scanic's neural detector (about 2.2 MB
55
55
  // downloaded with gzip the first time the scanner opens) instead of the
56
- // classical one; off by default.
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.
57
59
  //
58
60
  // It does not upload anything: when the user saves, it dispatches
59
61
  // "dile-document-scanned" with the files and closes. Closing it without saving
@@ -357,6 +359,19 @@ export class DileScanner extends LitElement {
357
359
  * ONNX runtime). Empty: they are downloaded from jsDelivr.
358
360
  */
359
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" },
360
375
  _step: { type: String, state: true },
361
376
  _pages: { type: Array, state: true },
362
377
  _busy: { type: Boolean, state: true },
@@ -376,6 +391,8 @@ export class DileScanner extends LitElement {
376
391
  this.labels = {};
377
392
  this.mlDetector = false;
378
393
  this.mlAssetsUrl = "";
394
+ this.mlMaxWait = 5000;
395
+ this.mlFastConnectionOnly = false;
379
396
  this._step = "capture";
380
397
  this._pages = [];
381
398
  this._busy = false;
@@ -396,6 +413,15 @@ export class DileScanner extends LitElement {
396
413
  return { ...DEFAULT_LABELS, ...this.labels };
397
414
  }
398
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
+
399
425
  get _modal() {
400
426
  return this.shadowRoot.getElementById("modal");
401
427
  }
@@ -404,7 +430,7 @@ export class DileScanner extends LitElement {
404
430
  open() {
405
431
  this._reset();
406
432
  this._saved = false;
407
- preloadEngine({ ml: this.mlDetector, mlAssetsUrl: this.mlAssetsUrl });
433
+ preloadEngine(this._engineOptions);
408
434
  this._modal.open();
409
435
  }
410
436
 
@@ -697,10 +723,7 @@ export class DileScanner extends LitElement {
697
723
  }
698
724
  try {
699
725
  const [corners, sourceBlob] = await Promise.all([
700
- detectCorners(canvas, {
701
- ml: this.mlDetector,
702
- mlAssetsUrl: this.mlAssetsUrl,
703
- }).catch(() => null),
726
+ detectCorners(canvas, this._engineOptions).catch(() => null),
704
727
  canvasToBlob(canvas, "image/jpeg", SOURCE_QUALITY),
705
728
  ]);
706
729
  if (session !== this._session) {
@@ -12,28 +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
42
  // so it is ready by the time the first photo arrives. With "ml" it also
17
- // downloads the neural detector files and creates its session, so the first
18
- // photo does not wait for about 2.2 MB. scanic keeps the session cached by
19
- // URL (also while it is loading), so detectCorners() reuses it and calling
20
- // this on every open() downloads nothing again. When it fails (e.g. no
21
- // network) scanic forgets it, so the next open() or photo tries again.
22
- // Scanner.initialize() ignores errors itself.
23
- export function preloadEngine({ ml = false, mlAssetsUrl = "" } = {}) {
24
- loadScanic()
25
- .then(({ Scanner }) => {
26
- if (ml) {
27
- return new Scanner({ detector: "ml", ml: mlOptions(mlAssetsUrl) }).initialize();
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 } = {}) {
46
+ loadScanic().catch(() => {
47
+ // Ignored here: the error shows up again when the engine is really used.
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;
28
68
  }
29
- })
30
- .catch(() => {
31
- // Ignored here: the error shows up again when the engine is really used.
32
- });
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
+ }
33
110
  }
34
111
 
35
112
  function mlOptions(mlAssetsUrl) {
36
- return mlAssetsUrl ? { assetBaseUrl: 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));
37
130
  }
38
131
 
39
132
  // Returns the corners of the document or null when none is found.
@@ -45,14 +138,21 @@ function mlOptions(mlAssetsUrl) {
45
138
  // always fail: it can return a wrong shape (e.g. a line of text) as if it
46
139
  // were the document. The neural detector downloads about 2.2 MB with gzip
47
140
  // (3.5 MB uncompressed: model + ONNX runtime) the first time (when the
48
- // scanner opens, see preloadEngine), from jsDelivr
49
- // unless mlAssetsUrl points to a self-hosted copy of the "scanic-ml" package
50
- // files. When it finds nothing or fails (e.g. no network), the classical
51
- // detector is used.
52
- export async function detectCorners(canvas, { ml = false, mlAssetsUrl = "" } = {}) {
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
+ ) {
53
153
  const { Scanner } = await loadScanic();
54
154
  scanner ??= new Scanner();
55
- if (ml) {
155
+ if (ml && (await withTimeout(loadMl({ mlAssetsUrl, mlFastConnectionOnly }), mlMaxWait))) {
56
156
  try {
57
157
  const mlResult = await scanner.scan(canvas, {
58
158
  mode: "detect",