dile-scanner
Web Component to scan documents in the browser, with no backend. The user takes photos or chooses images, the component detects the edges of the document, lets the user adjust the corners with a touch-friendly editor, straightens the perspective and compresses each page to JPEG. With multipage the user can also rotate, reorder, adjust again and remove pages.
When the user saves, the component returns a PDF (built in the browser with jsPDF) or the JPEG images through an event. It does not upload anything: sending the files is up to your application.
Edge detection and perspective correction are powered by scanic (Canny in Rust/WASM, with an optional neural detector).
Installation
npm i @dile/scanner
It is a separate package so @dile/ui users don't install scanic or jsPDF. Both are loaded with dynamic import(), so bundlers like Vite put them in separate chunks (scanic ~43 KB gzip, jsPDF ~129 KB gzip) that are only downloaded when the scanner is opened or the PDF is built.
Note: The ML detector is optional and off by default. The package also installs its files (
scanic-ml, 3.5 MB innode_modules) so you can serve your own copy if you enable it, but they never reach your users unless you useml-detector: they are not part of your JavaScript bundle and the browser does not download them.
Usage
Import the component.
import '@dile/scanner/scanner.js';
Use the component and open it from your code.
<dile-scanner
multipage
max-bytes="5242880"
></dile-scanner>
const scanner = document.querySelector('dile-scanner');
scanner.addEventListener('dile-document-scanned', (e) => upload(e.detail.file));
scanner.open();
Workflow
- Capture: the user chooses one or more images (several at once with
multipage). On touch devices (pointer: coarse) there is also a "Take photo" button that opens the rear camera. Selected images are processed in a queue: each one goes through the adjustment before the next one. - Adjust: the user drags the corners to the edges of the document. "Reset" restores the detected corners and "Use full image" keeps the whole photo.
- Pages: the user reviews the pages and saves. Without
multipage, a new image replaces the current page.
Photos are reduced to 3000 px on their longest side when read (iOS limits the area of a canvas and current phone cameras go well beyond it) and the EXIF orientation is applied. HEIC images work in browsers that can decode them (Safari).
Properties
| Property | Type | Default | Description |
|---|---|---|---|
multipage |
Boolean |
false |
Allows several pages in the same document. Without it, a new image replaces the current page. |
output |
String |
"pdf" |
"pdf": a single PDF with every page (also with one page). "images": one JPEG per page, always as an array. The format never depends on the number of pages. |
maxBytes |
Number |
0 |
Maximum size in bytes of the result; 0 means no limit. Exposed via max-bytes. |
maxSide |
Number |
2000 |
Longest side in pixels of each scanned page. Exposed via max-side. |
quality |
Number |
0.8 |
JPEG quality of each page, from 0 to 1. |
filename |
String |
"" |
File name without extension. Empty: labels.filenamePrefix plus date and time (scan-2026-10-19-1530.pdf). |
labels |
Object |
{} |
Partial object merged with the default texts (see Labels). Property only, no attribute. |
mlDetector |
Boolean |
false |
Detects the edges with scanic's neural detector (see ML detector). Exposed via ml-detector. |
mlAssetsUrl |
String |
"" |
Base URL of a self-hosted copy of the scanic-ml files. Empty: they are downloaded from jsDelivr. Exposed via ml-assets-url. |
Size limit
When max-bytes is set, the pages step shows the current size ("1.2 MB of 5 MB") and, with multipage, the add buttons are disabled once the limit is reached. If the result exceeds the limit when saving, the pages are encoded again in up to three smaller steps (1600 px / 0.7, 1400 px / 0.6 and 1200 px / 0.5) before showing an error. Lowering only the quality is not enough in Safari, so each step also reduces the size.
PDF output
Each PDF page takes the proportions of its image at 170 ppi: a full-width A4 scanned at 2000 px prints at roughly A4 size, while a receipt stays narrow. jsPDF embeds the JPEG files as they are (DCTDecode), without compressing them again, so the PDF weighs about the same as the sum of its pages.
Methods
- open(): Opens the scanner with an empty document.
- close(): Closes the scanner discarding the pages. Dispatches
dile-document-scan-cancelled.
Events
Both events bubble and are composed.
- dile-document-scanned: Dispatched when the user saves. The scanner closes afterwards.
- With
output="pdf":detailis{ file, pages }, wherefileis the PDF (File). - With
output="images":detailis{ files, pages }, wherefilesis aFile[]with one JPEG per page. - In both cases
pagescontains the JPEG of each page (File[]).
- With
- dile-document-scan-cancelled: Dispatched when the scanner is closed without saving (Cancel button, Esc key or
close()). Nodetail.
The internal dile-modal-closed event of the modal is stopped inside the component.
scanner.addEventListener('dile-document-scanned', async (e) => {
const body = new FormData();
body.append('document', e.detail.file);
await fetch('/api/documents', { method: 'POST', body });
});
Labels
The component has more than 30 texts, so instead of a property per text there is a single labels object. Pass only the texts you want to change; the rest keep their English defaults.
scanner.labels = {
title: 'Escanear documento',
chooseImage: 'Elegir imagen',
confirm: 'Guardar documento',
};
The default texts are exported as DEFAULT_LABELS, which is useful as a reference to translate them all:
import { DEFAULT_LABELS } from '@dile/scanner';
Available keys: title, captureHint, takePhoto, chooseImage, addPhoto, addImage, retakePhoto, replaceImage, adjustHint, notDetected, reset, useFullImage, accept, cancel, confirm, page, rotate, adjust, moveBefore, moveAfter, remove, processing, readError, processError, sizeLimitReached, tooBig, sizeOf, filenamePrefix, topLeftCorner, topRightCorner, bottomRightCorner, bottomLeftCorner.
The corner labels are used as the aria-label of the editor handles.
ML detector
With ml-detector, the edges are detected first with scanic's neural detector and, when it finds nothing or fails (no network, missing files), with the classical one. It goes first, not as a retry, because on hard photos (cluttered or light backgrounds, low contrast, strong perspective) the classical detector does not always fail: it can return a wrong shape, like a line of text, as if it were the document.
The ML detector is optional and off by default. Its weight depends on whether you use it:
- Without
ml-detector(default): your users download nothing of the ML detector. Its code is in separate chunks of scanic that are never requested. - With
ml-detector: the browser downloads about 2.2 MB the first time a photo is processed (not when the scanner opens). - In
node_modules: 3.5 MB always, because@dile/scannerinstallsscanic-mlas a dependency so you can self-host the files. It is only disk space in your project. - In your build: nothing, unless you add the Vite plugin, which copies the 3.5 MB of files to the output folder (not to your JavaScript bundle).
The files take 3.5 MB uncompressed and about 2.2 MB to download when the server compresses them with gzip (jsDelivr does):
| File | Content | Size | Gzip |
|---|---|---|---|
doccornernet_lean.ort |
Model (it barely compresses) | 1.93 MB | 1.67 MB |
ort-wasm-simd-threaded.wasm |
ONNX runtime | 1.52 MB | 0.54 MB |
ort-wasm-simd-threaded.mjs |
Runtime loader | 20 KB | 8 KB |
| Total | 3.47 MB | 2.22 MB |
By default they come from jsDelivr (scanic-ml@0.2.0, the version scanic 1.7.1 expects). @dile/scanner also installs that exact version of scanic-ml as a dependency, so you can serve your own copy, always compatible with the scanic version of the component. That avoids depending on the CDN: intranets, privacy, Content Security Policy or availability.
Self-hosting the ML files with Vite
Add the scannerMlAssets() plugin. It serves the files in the dev server and copies them to the build, with their original names, in the dile-scanner-ml/ folder:
// vite.config.js
import { defineConfig } from 'vite';
import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
export default defineConfig({
plugins: [scannerMlAssets()],
});
Then point the component to that folder:
<dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
The folder can be changed with scannerMlAssets({ path: 'assets/ml' }). The plugin adds the files (3.5 MB uncompressed) to the build output, but browsers only download them when the ML detector is used. Only add the plugin if you use ml-detector.
Self-hosting the ML files without Vite
Copy the three files from the scanic-ml package installed with @dile/scanner to a public folder of your site, keeping their names, and use that folder as ml-assets-url:
cp node_modules/scanic-ml/dist/* public/dile-scanner-ml/
If npm installed it nested, the files are in node_modules/@dile/scanner/node_modules/scanic-ml/dist/.
The server must send the .mjs file with a JavaScript content type (text/javascript); application/wasm for the .wasm file is recommended.
Note: Whatever the way you serve them, enable gzip or brotli compression for these files (many servers don't compress
.wasmor unknown extensions like.ortby default). Without it, the first use of the ML detector downloads 3.5 MB instead of about 2.2 MB.
Note: When building with Vite you will see the warning
new URL("ort.wasm.min.mjs", import.meta.url) doesn't exist at build time. It is harmless: it comes from scanic's ML loader, which resolves that path at runtime.
CSS Custom Properties
| Custom property | Description | Default |
|---|---|---|
| --dile-scanner-accent-color | Color of the corner handles and the document edges | --dile-primary-color or #6366f1 |
| --dile-scanner-mask-color | Mask over the area outside the document in the editor | rgba(15, 23, 42, 0.45) |
| --dile-scanner-editor-height | Height of the corner editor | min(62dvh, 640px) |
| --dile-scanner-editor-background-color | Background of the corner editor | #1e1e1e |
| --dile-scanner-modal-width | Width of the modal | min(96vw, 900px) |
| --dile-scanner-modal-max-height | Maximum height of the modal | 92dvh |
| --dile-scanner-modal-border-radius | Border radius of the modal | 15px |
| --dile-scanner-secondary-button-color | Color of the secondary buttons (Cancel, Reset...) | #888 |
On screens up to 600 px wide the modal takes the whole screen. The buttons are dile-button elements, so they also follow its custom properties.
Tips
- Testing on a phone: don't use Vite's dev server. When the camera opens, the browser goes to the background, Vite's websocket is cut and Vite reloads the page in the middle of the scan. Use a build instead (
vite build+vite preview --host) and, for HTTPS, a tunnel.
dile-scanner demos
Basic usage
A single page document returned as a PDF.
<script type="module">
import { LitElement, html } from 'lit';
import '@dile/scanner/scanner.js';
class ScannerDemo extends LitElement {
static properties = {
multipage: { type: Boolean },
output: { type: String },
maxBytes: { type: Number, attribute: 'max-bytes' },
mlDetector: { type: Boolean, attribute: 'ml-detector' },
mlAssetsUrl: { type: String, attribute: 'ml-assets-url' },
result: { type: Array, state: true },
cancelled: { type: Boolean, state: true },
};
constructor() {
super();
this.multipage = false;
this.output = 'pdf';
this.maxBytes = 0;
this.mlDetector = false;
this.mlAssetsUrl = '';
this.result = [];
this.cancelled = false;
}
render() {
return html`
<dile-button @click=${this.open}>Scan document</dile-button>
<dile-scanner
?multipage=${this.multipage}
output=${this.output}
max-bytes=${this.maxBytes}
?ml-detector=${this.mlDetector}
ml-assets-url=${this.mlAssetsUrl}
@dile-document-scanned=${this.scanned}
@dile-document-scan-cancelled=${() => (this.cancelled = true)}
></dile-scanner>
${this.cancelled ? html`<p>Scan cancelled.</p>` : ''}
<ul>
${this.result.map((file) => html`
<li><a href=${URL.createObjectURL(file)} target="_blank">${file.name}</a> (${Math.round(file.size / 1024)} KB)</li>
`)}
</ul>
`;
}
open() {
this.cancelled = false;
this.shadowRoot.querySelector('dile-scanner').open();
}
scanned(e) {
this.result = e.detail.file ? [e.detail.file] : e.detail.files;
}
}
customElements.define('scanner-demo', ScannerDemo);
</script>
<scanner-demo></scanner-demo>
Multipage with size limit
Add several pages, rotate, reorder, adjust and remove them. The result can't exceed 2 MB.
<scanner-demo multipage max-bytes="2097152"></scanner-demo>
JPEG images output
Returns one JPEG file per page instead of a PDF.
<scanner-demo multipage output="images"></scanner-demo>
ML detector
Detects the edges with the neural detector. The ML files are served from this site with the scannerMlAssets() Vite plugin and are only downloaded when the first photo is processed.
<scanner-demo ml-detector ml-assets-url="/dile-scanner-ml/"></scanner-demo>
Custom labels
<dile-button id="openScannerEs">Escanear documento</dile-button>
<dile-scanner id="scannerEs"></dile-scanner>
<script type="module">
const scanner = document.getElementById('scannerEs');
scanner.labels = {
title: 'Escanear documento',
captureHint: 'Haz una foto del documento o elige una imagen. Podrás ajustar los bordes antes de guardarlo.',
takePhoto: 'Hacer foto',
chooseImage: 'Elegir imagen',
retakePhoto: 'Repetir foto',
replaceImage: 'Elegir otra imagen',
adjustHint: 'Arrastra las esquinas hasta los bordes del documento.',
notDetected: 'No se han detectado los bordes. Arrastra las esquinas hasta los bordes del documento.',
reset: 'Restablecer',
useFullImage: 'Usar la imagen completa',
accept: 'Aceptar',
cancel: 'Cancelar',
confirm: 'Guardar documento',
processing: 'Procesando…',
filenamePrefix: 'escaneo',
};
document.getElementById('openScannerEs').addEventListener('click', () => scanner.open());
</script>
Styled
<style>
.styled-scanner {
--dile-scanner-accent-color: #e67e22;
--dile-scanner-mask-color: rgba(230, 126, 34, 0.25);
--dile-scanner-editor-background-color: #2c3e50;
--dile-scanner-modal-width: min(96vw, 700px);
--dile-scanner-modal-border-radius: 4px;
--dile-scanner-secondary-button-color: #7f8c8d;
}
</style>
<dile-button id="openStyledScanner">Scan document</dile-button>
<dile-scanner id="styledScanner" class="styled-scanner"></dile-scanner>
<script type="module">
document.getElementById('openStyledScanner')
.addEventListener('click', () => document.getElementById('styledScanner').open());
</script>
dile-components