@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,101 @@
|
|
|
1
|
+
import { css } from "lit";
|
|
2
|
+
|
|
3
|
+
// Styles of scanic's corner editor (scanic 1.7.1). scanic injects them into
|
|
4
|
+
// document.head, which does not reach the shadow root, so the component
|
|
5
|
+
// includes them. Only the handles are needed: the toolbar and the nudge pad
|
|
6
|
+
// are disabled. Review this copy when updating scanic.
|
|
7
|
+
export const cornerEditorStyles = css`
|
|
8
|
+
.scanic-corner-editor {
|
|
9
|
+
--scanic-accent: var(--dile-scanner-accent-color, var(--dile-primary-color, #6366f1));
|
|
10
|
+
--scanic-mask: var(--dile-scanner-mask-color, rgba(15, 23, 42, 0.45));
|
|
11
|
+
--scanic-edge-color: var(--scanic-accent);
|
|
12
|
+
--scanic-edge-width: 2.5;
|
|
13
|
+
--scanic-handle-size: 22px;
|
|
14
|
+
--scanic-handle-hit: 44px;
|
|
15
|
+
--scanic-handle-color: #ffffff;
|
|
16
|
+
--scanic-handle-ring: 2px;
|
|
17
|
+
--scanic-handle-ring-color: var(--scanic-accent);
|
|
18
|
+
--scanic-handle-shadow: 0 1px 4px rgba(15, 23, 42, 0.45);
|
|
19
|
+
--scanic-handle-active-shadow: 0 8px 22px rgba(15, 23, 42, 0.5);
|
|
20
|
+
--scanic-handle-active-scale: 1.28;
|
|
21
|
+
}
|
|
22
|
+
.scanic-handle {
|
|
23
|
+
position: absolute;
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
width: var(--scanic-handle-size);
|
|
26
|
+
height: var(--scanic-handle-size);
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: 0;
|
|
29
|
+
border: var(--scanic-handle-ring) solid var(--scanic-handle-ring-color);
|
|
30
|
+
border-radius: 50%;
|
|
31
|
+
background: var(--scanic-handle-color);
|
|
32
|
+
box-shadow: var(--scanic-handle-shadow);
|
|
33
|
+
transform: translate(-50%, -50%);
|
|
34
|
+
cursor: grab;
|
|
35
|
+
touch-action: none;
|
|
36
|
+
z-index: 2;
|
|
37
|
+
transition:
|
|
38
|
+
transform 0.12s ease,
|
|
39
|
+
box-shadow 0.12s ease,
|
|
40
|
+
background 0.12s ease,
|
|
41
|
+
border-color 0.12s ease;
|
|
42
|
+
}
|
|
43
|
+
/* Enlarges the touch target without changing the visual size. */
|
|
44
|
+
.scanic-handle::after {
|
|
45
|
+
content: "";
|
|
46
|
+
position: absolute;
|
|
47
|
+
left: 50%;
|
|
48
|
+
top: 50%;
|
|
49
|
+
width: var(--scanic-handle-hit);
|
|
50
|
+
height: var(--scanic-handle-hit);
|
|
51
|
+
transform: translate(-50%, -50%);
|
|
52
|
+
border-radius: 50%;
|
|
53
|
+
}
|
|
54
|
+
.scanic-handle::before {
|
|
55
|
+
content: "";
|
|
56
|
+
position: absolute;
|
|
57
|
+
left: 50%;
|
|
58
|
+
top: 50%;
|
|
59
|
+
width: 0;
|
|
60
|
+
height: 0;
|
|
61
|
+
border-radius: 50%;
|
|
62
|
+
background: var(--scanic-accent);
|
|
63
|
+
opacity: 0;
|
|
64
|
+
transform: translate(-50%, -50%);
|
|
65
|
+
transition:
|
|
66
|
+
opacity 0.15s ease,
|
|
67
|
+
width 0.15s ease,
|
|
68
|
+
height 0.15s ease;
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
}
|
|
71
|
+
.scanic-handle:hover {
|
|
72
|
+
transform: translate(-50%, -50%) scale(1.12);
|
|
73
|
+
}
|
|
74
|
+
.scanic-handle:focus-visible {
|
|
75
|
+
outline: none;
|
|
76
|
+
box-shadow:
|
|
77
|
+
0 0 0 3px color-mix(in srgb, var(--scanic-accent) 45%, transparent),
|
|
78
|
+
var(--scanic-handle-shadow);
|
|
79
|
+
}
|
|
80
|
+
.scanic-handle.is-selected {
|
|
81
|
+
border-color: var(--scanic-accent);
|
|
82
|
+
transform: translate(-50%, -50%) scale(1.12);
|
|
83
|
+
}
|
|
84
|
+
.scanic-handle.is-selected::before {
|
|
85
|
+
opacity: 0.16;
|
|
86
|
+
width: calc(var(--scanic-handle-size) * 2);
|
|
87
|
+
height: calc(var(--scanic-handle-size) * 2);
|
|
88
|
+
}
|
|
89
|
+
.scanic-handle.is-active {
|
|
90
|
+
cursor: grabbing;
|
|
91
|
+
background: var(--scanic-accent);
|
|
92
|
+
border-color: #ffffff;
|
|
93
|
+
transform: translate(-50%, -50%) scale(var(--scanic-handle-active-scale));
|
|
94
|
+
box-shadow: var(--scanic-handle-active-shadow);
|
|
95
|
+
}
|
|
96
|
+
.scanic-handle.is-active::before {
|
|
97
|
+
opacity: 0.22;
|
|
98
|
+
width: calc(var(--scanic-handle-size) * 2.8);
|
|
99
|
+
height: calc(var(--scanic-handle-size) * 2.8);
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Texts of the scanner. Pass a partial object in the "labels" property to
|
|
2
|
+
// translate or change any of them; the missing ones keep these defaults.
|
|
3
|
+
export const DEFAULT_LABELS = {
|
|
4
|
+
title: "Scan document",
|
|
5
|
+
captureHint:
|
|
6
|
+
"Take a photo of the document or choose an image. You will be able to adjust the edges before saving it.",
|
|
7
|
+
takePhoto: "Take photo",
|
|
8
|
+
chooseImage: "Choose image",
|
|
9
|
+
addPhoto: "Add photo",
|
|
10
|
+
addImage: "Add image",
|
|
11
|
+
retakePhoto: "Repeat photo",
|
|
12
|
+
replaceImage: "Choose another image",
|
|
13
|
+
adjustHint: "Drag the corners to the edges of the document.",
|
|
14
|
+
notDetected:
|
|
15
|
+
"The edges of the document were not detected. Drag the corners to the edges of the document.",
|
|
16
|
+
reset: "Reset",
|
|
17
|
+
useFullImage: "Use full image",
|
|
18
|
+
accept: "Accept",
|
|
19
|
+
cancel: "Cancel",
|
|
20
|
+
confirm: "Save document",
|
|
21
|
+
page: "Page",
|
|
22
|
+
rotate: "Rotate",
|
|
23
|
+
adjust: "Adjust edges",
|
|
24
|
+
moveBefore: "Move before",
|
|
25
|
+
moveAfter: "Move after",
|
|
26
|
+
remove: "Remove page",
|
|
27
|
+
processing: "Processing…",
|
|
28
|
+
readError:
|
|
29
|
+
"The image could not be read. Try with another image (JPG, PNG or WebP).",
|
|
30
|
+
processError: "The image could not be processed. Try again.",
|
|
31
|
+
sizeLimitReached:
|
|
32
|
+
"The document has reached the maximum size. Remove a page to add another one.",
|
|
33
|
+
tooBig:
|
|
34
|
+
"The document exceeds the maximum size even with reduced quality. Remove some pages.",
|
|
35
|
+
sizeOf: "of",
|
|
36
|
+
filenamePrefix: "scan",
|
|
37
|
+
topLeftCorner: "Top-left corner",
|
|
38
|
+
topRightCorner: "Top-right corner",
|
|
39
|
+
bottomRightCorner: "Bottom-right corner",
|
|
40
|
+
bottomLeftCorner: "Bottom-left corner",
|
|
41
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Canvas helpers for the scanner. Every image is handled as a canvas while it
|
|
2
|
+
// is being processed and stored as a JPEG Blob afterwards, so a document with
|
|
3
|
+
// many pages does not keep several full-size canvases in memory.
|
|
4
|
+
|
|
5
|
+
// Decodes an image file into a canvas, applying the EXIF orientation and
|
|
6
|
+
// reducing it so its longest side is at most maxSide. iOS limits the area of
|
|
7
|
+
// a canvas (about 16.7 megapixels) and current phone cameras go well beyond
|
|
8
|
+
// that, so the reduction is also what makes big photos work at all.
|
|
9
|
+
export async function fileToCanvas(file, maxSide) {
|
|
10
|
+
const bitmap = await createImageBitmap(file, {
|
|
11
|
+
imageOrientation: "from-image",
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
return drawScaled(bitmap, bitmap.width, bitmap.height, maxSide);
|
|
15
|
+
} finally {
|
|
16
|
+
bitmap.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function blobToCanvas(blob) {
|
|
21
|
+
const bitmap = await createImageBitmap(blob);
|
|
22
|
+
try {
|
|
23
|
+
return drawScaled(bitmap, bitmap.width, bitmap.height, Infinity);
|
|
24
|
+
} finally {
|
|
25
|
+
bitmap.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function canvasToBlob(canvas, type = "image/jpeg", quality = 0.92) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
canvas.toBlob(
|
|
32
|
+
(blob) =>
|
|
33
|
+
blob ? resolve(blob) : reject(new Error("Canvas could not be encoded")),
|
|
34
|
+
type,
|
|
35
|
+
quality,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Rotates clockwise in steps of 90 degrees.
|
|
41
|
+
export function rotateCanvas(canvas, rotation) {
|
|
42
|
+
const turns = (((rotation / 90) % 4) + 4) % 4;
|
|
43
|
+
if (turns === 0) {
|
|
44
|
+
return canvas;
|
|
45
|
+
}
|
|
46
|
+
const rotated = document.createElement("canvas");
|
|
47
|
+
const swapSides = turns % 2 === 1;
|
|
48
|
+
rotated.width = swapSides ? canvas.height : canvas.width;
|
|
49
|
+
rotated.height = swapSides ? canvas.width : canvas.height;
|
|
50
|
+
const context = rotated.getContext("2d");
|
|
51
|
+
context.translate(rotated.width / 2, rotated.height / 2);
|
|
52
|
+
context.rotate((turns * Math.PI) / 2);
|
|
53
|
+
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2);
|
|
54
|
+
return rotated;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Final encoding of a page: limits the longest side and compresses to JPEG
|
|
58
|
+
// over a white background (a transparent PNG would turn black otherwise).
|
|
59
|
+
export async function encodeJpeg(canvas, { maxSide, quality }) {
|
|
60
|
+
const scaled = drawScaled(canvas, canvas.width, canvas.height, maxSide, true);
|
|
61
|
+
const blob = await canvasToBlob(scaled, "image/jpeg", quality);
|
|
62
|
+
return { blob, width: scaled.width, height: scaled.height };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function fullImageCorners(width, height) {
|
|
66
|
+
return {
|
|
67
|
+
topLeft: { x: 0, y: 0 },
|
|
68
|
+
topRight: { x: width, y: 0 },
|
|
69
|
+
bottomRight: { x: width, y: height },
|
|
70
|
+
bottomLeft: { x: 0, y: height },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function drawScaled(source, width, height, maxSide, whiteBackground = false) {
|
|
75
|
+
const scale = Math.min(1, maxSide / Math.max(width, height));
|
|
76
|
+
const canvas = document.createElement("canvas");
|
|
77
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
78
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
79
|
+
const context = canvas.getContext("2d");
|
|
80
|
+
if (whiteBackground) {
|
|
81
|
+
context.fillStyle = "#ffffff";
|
|
82
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
83
|
+
}
|
|
84
|
+
context.drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
85
|
+
return canvas;
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Joins the scanned pages (JPEG) into a PDF. jsPDF embeds each JPEG as it is
|
|
2
|
+
// (DCTDecode), without encoding it again, so the PDF weighs about the same as
|
|
3
|
+
// the sum of its pages.
|
|
4
|
+
//
|
|
5
|
+
// Each PDF page takes the proportions of its image (a receipt stays narrow
|
|
6
|
+
// instead of being stretched to A4). The physical size is computed at
|
|
7
|
+
// PAGE_DPI, so a full-width scan of an A4 page prints at roughly A4 size.
|
|
8
|
+
|
|
9
|
+
const PAGE_DPI = 170;
|
|
10
|
+
const POINTS_PER_INCH = 72;
|
|
11
|
+
|
|
12
|
+
export async function buildPdf(pages, filename) {
|
|
13
|
+
const { jsPDF } = await import("jspdf");
|
|
14
|
+
let pdf;
|
|
15
|
+
for (const page of pages) {
|
|
16
|
+
const width = (page.width * POINTS_PER_INCH) / PAGE_DPI;
|
|
17
|
+
const height = (page.height * POINTS_PER_INCH) / PAGE_DPI;
|
|
18
|
+
const orientation = width > height ? "landscape" : "portrait";
|
|
19
|
+
if (pdf) {
|
|
20
|
+
pdf.addPage([width, height], orientation);
|
|
21
|
+
} else {
|
|
22
|
+
pdf = new jsPDF({ unit: "pt", format: [width, height], orientation });
|
|
23
|
+
}
|
|
24
|
+
const bytes = new Uint8Array(await page.blob.arrayBuffer());
|
|
25
|
+
pdf.addImage(bytes, "JPEG", 0, 0, width, height, undefined, "NONE");
|
|
26
|
+
}
|
|
27
|
+
return new File([pdf.output("blob")], filename, {
|
|
28
|
+
type: "application/pdf",
|
|
29
|
+
lastModified: Date.now(),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The only module that talks to scanic (edge detection, perspective
|
|
2
|
+
// correction and the corner editor). Keeping it isolated allows replacing the
|
|
3
|
+
// library (e.g. with OpenCV.js) without touching the component.
|
|
4
|
+
//
|
|
5
|
+
// scanic is loaded on demand, so it is only downloaded when someone scans.
|
|
6
|
+
|
|
7
|
+
let scanicModule;
|
|
8
|
+
let scanner;
|
|
9
|
+
|
|
10
|
+
function loadScanic() {
|
|
11
|
+
scanicModule ??= import("scanic");
|
|
12
|
+
return scanicModule;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 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() {
|
|
18
|
+
loadScanic().catch(() => {
|
|
19
|
+
// Ignored here: the error shows up again when the engine is really used.
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Returns the corners of the document or null when none is found.
|
|
24
|
+
//
|
|
25
|
+
// By default it uses the classical detector (Canny, included in scanic).
|
|
26
|
+
// With "ml" it uses scanic's neural detector first, more robust with
|
|
27
|
+
// cluttered backgrounds, low contrast or strong perspective. It goes first,
|
|
28
|
+
// not as a retry, because on hard photos the classical detector does not
|
|
29
|
+
// always fail: it can return a wrong shape (e.g. a line of text) as if it
|
|
30
|
+
// 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 = "" } = {}) {
|
|
36
|
+
const { Scanner } = await loadScanic();
|
|
37
|
+
scanner ??= new Scanner();
|
|
38
|
+
if (ml) {
|
|
39
|
+
try {
|
|
40
|
+
const mlResult = await scanner.scan(canvas, {
|
|
41
|
+
mode: "detect",
|
|
42
|
+
detector: "ml",
|
|
43
|
+
ml: mlAssetsUrl ? { assetBaseUrl: mlAssetsUrl } : {},
|
|
44
|
+
});
|
|
45
|
+
if (mlResult.success) {
|
|
46
|
+
return mlResult.corners;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Falls back to the classical detector.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const result = await scanner.scan(canvas, { mode: "detect" });
|
|
53
|
+
return result.success ? result.corners : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Perspective correction: returns a new canvas with the document straightened.
|
|
57
|
+
export async function extractDocument(canvas, corners) {
|
|
58
|
+
const { extractDocument } = await loadScanic();
|
|
59
|
+
const result = await extractDocument(canvas, corners, { output: "canvas" });
|
|
60
|
+
if (!result.success || !result.output) {
|
|
61
|
+
throw new Error(result.message || "The document could not be extracted");
|
|
62
|
+
}
|
|
63
|
+
return result.output;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// scanic injects its stylesheet into document.head, which does not reach a
|
|
67
|
+
// shadow root, so it is disabled and the component provides the styles
|
|
68
|
+
// (corner-editor-styles.js). Its toolbar is disabled too: the component uses
|
|
69
|
+
// its own buttons, calling reset(), getCorners() and setCorners().
|
|
70
|
+
export async function createCornerEditor({ container, image, corners, labels }) {
|
|
71
|
+
const { createCornerEditor } = await loadScanic();
|
|
72
|
+
const editor = createCornerEditor({
|
|
73
|
+
container,
|
|
74
|
+
image,
|
|
75
|
+
corners: corners ?? undefined,
|
|
76
|
+
injectStyles: false,
|
|
77
|
+
toolbar: { enabled: false },
|
|
78
|
+
magnifier: { zoom: 2, size: 110 },
|
|
79
|
+
});
|
|
80
|
+
translateHandles(container, labels);
|
|
81
|
+
return editor;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// scanic labels the handles in English and does not allow changing it.
|
|
85
|
+
function translateHandles(container, labels) {
|
|
86
|
+
const handleLabels = {
|
|
87
|
+
topLeft: labels.topLeftCorner,
|
|
88
|
+
topRight: labels.topRightCorner,
|
|
89
|
+
bottomRight: labels.bottomRightCorner,
|
|
90
|
+
bottomLeft: labels.bottomLeftCorner,
|
|
91
|
+
};
|
|
92
|
+
for (const handle of container.querySelectorAll(".scanic-handle")) {
|
|
93
|
+
const label = handleLabels[handle.dataset.corner];
|
|
94
|
+
if (label) {
|
|
95
|
+
handle.setAttribute("aria-label", label);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Shared helpers for the dile-scanner component tests.
|
|
2
|
+
|
|
3
|
+
// Synthetic photo of a document: dark noisy background and a light sheet of
|
|
4
|
+
// paper in perspective with some lines of "text".
|
|
5
|
+
export function documentCanvas({ width = 1200, height = 900, background = 'dark' } = {}) {
|
|
6
|
+
const canvas = document.createElement('canvas');
|
|
7
|
+
canvas.width = width;
|
|
8
|
+
canvas.height = height;
|
|
9
|
+
const context = canvas.getContext('2d');
|
|
10
|
+
context.fillStyle = background === 'dark' ? '#2b2b2b' : '#d8d4cc';
|
|
11
|
+
context.fillRect(0, 0, width, height);
|
|
12
|
+
for (let i = 0; i < 4000; i++) {
|
|
13
|
+
const shade = background === 'dark' ? 30 + Math.random() * 40 : 190 + Math.random() * 40;
|
|
14
|
+
context.fillStyle = `rgb(${shade},${shade},${shade})`;
|
|
15
|
+
context.fillRect(Math.random() * width, Math.random() * height, 3, 3);
|
|
16
|
+
}
|
|
17
|
+
const quad = [
|
|
18
|
+
[width * 0.22, height * 0.12],
|
|
19
|
+
[width * 0.8, height * 0.16],
|
|
20
|
+
[width * 0.84, height * 0.88],
|
|
21
|
+
[width * 0.16, height * 0.84],
|
|
22
|
+
];
|
|
23
|
+
context.fillStyle = '#f7f7f2';
|
|
24
|
+
context.beginPath();
|
|
25
|
+
quad.forEach(([x, y], index) => (index ? context.lineTo(x, y) : context.moveTo(x, y)));
|
|
26
|
+
context.closePath();
|
|
27
|
+
context.fill();
|
|
28
|
+
context.fillStyle = '#555';
|
|
29
|
+
for (let line = 0; line < 12; line++) {
|
|
30
|
+
const y = height * (0.25 + line * 0.045);
|
|
31
|
+
context.fillRect(width * 0.3, y, width * (0.35 + (line % 3) * 0.05), 6);
|
|
32
|
+
}
|
|
33
|
+
return canvas;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Light sheet of paper with dark lines of text over a light wood-like
|
|
37
|
+
// texture. The classical detector picks a line of text as the document; the
|
|
38
|
+
// ML detector finds the sheet. Uses a seeded random so it is always the same.
|
|
39
|
+
export function hardDocumentCanvas({ width = 1200, height = 900, seed = 7 } = {}) {
|
|
40
|
+
const random = seededRandom(seed);
|
|
41
|
+
const canvas = document.createElement('canvas');
|
|
42
|
+
canvas.width = width;
|
|
43
|
+
canvas.height = height;
|
|
44
|
+
const context = canvas.getContext('2d');
|
|
45
|
+
context.fillStyle = '#e2ddd3';
|
|
46
|
+
context.fillRect(0, 0, width, height);
|
|
47
|
+
for (let i = 0; i < 260; i++) {
|
|
48
|
+
const shade = 170 + random() * 60;
|
|
49
|
+
const y = random() * height;
|
|
50
|
+
context.strokeStyle = `rgb(${shade},${shade - 8},${shade - 20})`;
|
|
51
|
+
context.lineWidth = 1 + random() * 4;
|
|
52
|
+
context.beginPath();
|
|
53
|
+
context.moveTo(0, y);
|
|
54
|
+
context.bezierCurveTo(width / 3, y + 30, (width * 2) / 3, y - 30, width, y + random() * 20);
|
|
55
|
+
context.stroke();
|
|
56
|
+
}
|
|
57
|
+
const quad = [
|
|
58
|
+
[width * 0.22, height * 0.12],
|
|
59
|
+
[width * 0.8, height * 0.16],
|
|
60
|
+
[width * 0.84, height * 0.88],
|
|
61
|
+
[width * 0.16, height * 0.84],
|
|
62
|
+
];
|
|
63
|
+
context.fillStyle = '#f5f4ef';
|
|
64
|
+
context.beginPath();
|
|
65
|
+
quad.forEach(([x, y], index) => (index ? context.lineTo(x, y) : context.moveTo(x, y)));
|
|
66
|
+
context.closePath();
|
|
67
|
+
context.fill();
|
|
68
|
+
context.fillStyle = '#222';
|
|
69
|
+
for (let line = 0; line < 14; line++) {
|
|
70
|
+
const y = height * (0.22 + line * 0.045);
|
|
71
|
+
context.fillRect(width * 0.28, y, width * (0.4 + (line % 3) * 0.05), 9);
|
|
72
|
+
}
|
|
73
|
+
return canvas;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function seededRandom(seed) {
|
|
77
|
+
return () => {
|
|
78
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
79
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
80
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
81
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function canvasToFile(canvas, name = 'photo.jpg', type = 'image/jpeg') {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
canvas.toBlob((blob) => resolve(new File([blob], name, { type })), type, 0.92);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function documentFile(options) {
|
|
92
|
+
return canvasToFile(documentCanvas(options));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function waitFor(fn, timeout = 15000) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
const check = () => {
|
|
99
|
+
const result = fn();
|
|
100
|
+
if (result) return resolve(result);
|
|
101
|
+
if (Date.now() - start > timeout) return reject(new Error('waitFor timed out'));
|
|
102
|
+
setTimeout(check, 20);
|
|
103
|
+
};
|
|
104
|
+
check();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nextEvent(el, eventName) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
el.addEventListener(eventName, resolve, { once: true });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function imageSize(blob) {
|
|
115
|
+
const bitmap = await createImageBitmap(blob);
|
|
116
|
+
const size = { width: bitmap.width, height: bitmap.height };
|
|
117
|
+
bitmap.close();
|
|
118
|
+
return size;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function renderScanner(attrs = '') {
|
|
122
|
+
document.body.innerHTML = `<dile-scanner ${attrs}></dile-scanner>`;
|
|
123
|
+
const el = document.body.querySelector('dile-scanner');
|
|
124
|
+
await el.updateComplete;
|
|
125
|
+
return el;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function openScanner(attrs) {
|
|
129
|
+
const el = await renderScanner(attrs);
|
|
130
|
+
el.open();
|
|
131
|
+
await el.updateComplete;
|
|
132
|
+
return el;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const query = (el, selector) => el.shadowRoot.querySelector(selector);
|
|
136
|
+
export const queryAll = (el, selector) => [...el.shadowRoot.querySelectorAll(selector)];
|
|
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}"]`);
|
|
139
|
+
export const pages = (el) => queryAll(el, '.page');
|
|
140
|
+
export const errorMessage = (el) => query(el, '.message.error')?.textContent.trim();
|
|
141
|
+
|
|
142
|
+
export function selectFiles(el, files, inputId = 'galleryinput') {
|
|
143
|
+
const input = query(el, `#${inputId}`);
|
|
144
|
+
const transfer = new DataTransfer();
|
|
145
|
+
files.forEach((file) => transfer.items.add(file));
|
|
146
|
+
input.files = transfer.files;
|
|
147
|
+
input.dispatchEvent(new Event('change'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function waitForIdle(el) {
|
|
151
|
+
await el.updateComplete;
|
|
152
|
+
await waitFor(() => !query(el, '.busy'));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function waitForEditor(el) {
|
|
156
|
+
return waitFor(() => !query(el, '.busy') && query(el, '#editor .scanic-handle'));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function waitForPages(el, count) {
|
|
160
|
+
return waitFor(() => !query(el, '.busy') && pages(el).length === count && query(el, '.pages'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function acceptAdjust(el) {
|
|
164
|
+
await waitForEditor(el);
|
|
165
|
+
button(el, 'check').click();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Adds the files, accepting the detected corners of each one.
|
|
169
|
+
export async function scanFiles(el, files) {
|
|
170
|
+
const expected = pages(el).length + (el.multipage ? files.length : Math.max(0, 1 - pages(el).length));
|
|
171
|
+
selectFiles(el, files);
|
|
172
|
+
for (let i = 0; i < (el.multipage ? files.length : 1); i++) {
|
|
173
|
+
await acceptAdjust(el);
|
|
174
|
+
await waitForIdle(el);
|
|
175
|
+
}
|
|
176
|
+
await waitForPages(el, expected);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function save(el) {
|
|
180
|
+
const scanned = nextEvent(el, 'dile-document-scanned');
|
|
181
|
+
button(el, 'check').click();
|
|
182
|
+
return (await scanned).detail;
|
|
183
|
+
}
|
package/vite-plugin.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Vite plugin that serves a self-hosted copy of the ML detector files
|
|
2
|
+
// ("scanic-ml" package, installed with @dile/scanner at the version that
|
|
3
|
+
// matches its scanic version). Use it with the "ml-assets-url" attribute:
|
|
4
|
+
//
|
|
5
|
+
// // vite.config.js
|
|
6
|
+
// import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
|
|
7
|
+
// export default { plugins: [scannerMlAssets()] };
|
|
8
|
+
//
|
|
9
|
+
// <dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
|
|
10
|
+
//
|
|
11
|
+
// The files are served by the dev server and copied to the build with their
|
|
12
|
+
// original names (scanic expects them together in one folder). They are only
|
|
13
|
+
// downloaded by the browser when the ML detector is actually used.
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, extname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const ML_ASSET_FILES = [
|
|
19
|
+
"doccornernet_lean.ort",
|
|
20
|
+
"ort-wasm-simd-threaded.mjs",
|
|
21
|
+
"ort-wasm-simd-threaded.wasm",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const CONTENT_TYPES = {
|
|
25
|
+
".ort": "application/octet-stream",
|
|
26
|
+
".mjs": "text/javascript",
|
|
27
|
+
".wasm": "application/wasm",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Folder of the "scanic-ml" files, resolved from this package so it is the
|
|
31
|
+
// version pinned by @dile/scanner.
|
|
32
|
+
export function mlAssetsDir() {
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
return join(dirname(require.resolve("scanic-ml/package.json")), "dist");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function scannerMlAssets({ path = "dile-scanner-ml" } = {}) {
|
|
38
|
+
const folder = path.replace(/^\/+|\/+$/g, "");
|
|
39
|
+
const assetsDir = mlAssetsDir();
|
|
40
|
+
return {
|
|
41
|
+
name: "dile-scanner-ml-assets",
|
|
42
|
+
configureServer(server) {
|
|
43
|
+
server.middlewares.use((req, res, next) => {
|
|
44
|
+
const prefix = `${server.config.base}${folder}/`;
|
|
45
|
+
const url = req.url.split("?")[0];
|
|
46
|
+
const file = url.startsWith(prefix) ? url.slice(prefix.length) : null;
|
|
47
|
+
if (!ML_ASSET_FILES.includes(file)) {
|
|
48
|
+
return next();
|
|
49
|
+
}
|
|
50
|
+
res.setHeader("Content-Type", CONTENT_TYPES[extname(file)]);
|
|
51
|
+
res.end(readFileSync(join(assetsDir, file)));
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
generateBundle() {
|
|
55
|
+
for (const file of ML_ASSET_FILES) {
|
|
56
|
+
this.emitFile({
|
|
57
|
+
type: "asset",
|
|
58
|
+
fileName: `${folder}/${file}`,
|
|
59
|
+
source: readFileSync(join(assetsDir, file)),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|