scan2form 1.0.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/bridge-server.js +150 -0
- package/dist/esm/scanner-client.js +54 -0
- package/dist/scanner-client.js +58 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yousuf Omer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Scan2Form 🖨️ -> 🌐
|
|
2
|
+
|
|
3
|
+
**Scan documents from a physical scanner directly into your web form.**
|
|
4
|
+
|
|
5
|
+
Typically, browsers can't access scanners. **Scan2Form** solves this by running a tiny local bridge on your computer.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🚀 Quick Start for Developers
|
|
10
|
+
|
|
11
|
+
**1. Install the package**
|
|
12
|
+
```bash
|
|
13
|
+
npm install scan2form
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**2. Add to your frontend code**
|
|
17
|
+
```javascript
|
|
18
|
+
import { Scan2Form } from 'scan2form';
|
|
19
|
+
|
|
20
|
+
const scanner = new Scan2Form();
|
|
21
|
+
|
|
22
|
+
// Triggers the scan and puts the file into <input type="file" id="my-input" />
|
|
23
|
+
await scanner.scanToInput('my-input');
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it! The file input is now populated with a PDF, just as if the user uploaded it manually.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 🖥️ Setup for End-Users
|
|
31
|
+
|
|
32
|
+
To make scanning work, the user needs two things installed on their computer:
|
|
33
|
+
|
|
34
|
+
### 1. The Scanner Engine
|
|
35
|
+
We rely on a local scanning engine. You can use either **NAPS2** (Recommended for Windows) or **SANE** (Recommended for macOS/Linux).
|
|
36
|
+
|
|
37
|
+
**Option A: NAPS2 (Windows)**
|
|
38
|
+
* **[Download NAPS2](https://github.com/cyanfish/naps2/releases#:~:text=naps2%2D8.2.1%2Dwin%2Darm64.exe)** and install it.
|
|
39
|
+
* **Important:** Ensure `naps2.console` is available in your system PATH.
|
|
40
|
+
* [How to configure NAPS2 Command Line](https://www.naps2.com/doc/command-line)
|
|
41
|
+
|
|
42
|
+
> **Tip (Windows PowerShell):** You can set up an alias to make it simple:
|
|
43
|
+
> ```powershell
|
|
44
|
+
> function naps2.console { . "C:\Program Files\NAPS2\NAPS2.Console.exe" $args }
|
|
45
|
+
> ```
|
|
46
|
+
|
|
47
|
+
**Option B: SANE (macOS / Linux)**
|
|
48
|
+
* **macOS:** Install via Homebrew: `brew install sane-backends`
|
|
49
|
+
* **Linux:** Install via apt: `sudo apt-get install sane-utils`
|
|
50
|
+
* Verify installation by running `scanimage --version` in your terminal.
|
|
51
|
+
|
|
52
|
+
### 2. The Bridge Server
|
|
53
|
+
This tiny server listens for commands from your website.
|
|
54
|
+
```bash
|
|
55
|
+
# Run this command in your terminal
|
|
56
|
+
npx scan2form-server
|
|
57
|
+
```
|
|
58
|
+
* Keep this running while scanning.
|
|
59
|
+
* It runs locally at `http://127.0.0.1:3000`.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 🛠️ System Requirements
|
|
64
|
+
* **Node.js**: v16+
|
|
65
|
+
* **OS**: Windows, Mac, or Linux
|
|
66
|
+
* **Scanner**: Any scanner supported by your OS drivers.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.app = void 0;
|
|
8
|
+
const express_1 = __importDefault(require("express"));
|
|
9
|
+
const cors_1 = __importDefault(require("cors"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const uuid_1 = require("uuid");
|
|
14
|
+
const app = (0, express_1.default)();
|
|
15
|
+
exports.app = app;
|
|
16
|
+
const PORT = 3000; // Localhost only
|
|
17
|
+
// Rule 9.1: Bind only to localhost (enforced in listen)
|
|
18
|
+
// Rule 9.3: Secure temp directory
|
|
19
|
+
const TEMP_DIR = path_1.default.join(__dirname, 'temp_scans');
|
|
20
|
+
if (!fs_1.default.existsSync(TEMP_DIR))
|
|
21
|
+
fs_1.default.mkdirSync(TEMP_DIR);
|
|
22
|
+
app.use((0, cors_1.default)()); // Allow browser to hit localhost
|
|
23
|
+
app.use(express_1.default.json());
|
|
24
|
+
// Rule 4.2: Health Check
|
|
25
|
+
app.get('/health', (req, res) => {
|
|
26
|
+
res.json({ status: "ok", engine: "NAPS2-Wrapper", version: "1.0.0" });
|
|
27
|
+
});
|
|
28
|
+
// Serve example for testing
|
|
29
|
+
app.use('/example', express_1.default.static(path_1.default.join(__dirname, '../example')));
|
|
30
|
+
app.use('/dist', express_1.default.static(path_1.default.join(__dirname, '../dist')));
|
|
31
|
+
// --- Scanner Engine Selection ---
|
|
32
|
+
// We check for 'naps2.console' first, then 'scanimage' (SANE).
|
|
33
|
+
function getScannerEngine(callback) {
|
|
34
|
+
(0, child_process_1.exec)('naps2.console --help', (err) => {
|
|
35
|
+
if (!err)
|
|
36
|
+
return callback('naps2');
|
|
37
|
+
(0, child_process_1.exec)('scanimage --version', (err2) => {
|
|
38
|
+
if (!err2)
|
|
39
|
+
return callback('sane');
|
|
40
|
+
callback(null);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// --- Endpoints ---
|
|
45
|
+
app.get('/devices', (req, res) => {
|
|
46
|
+
getScannerEngine((engine) => {
|
|
47
|
+
if (engine === 'naps2') {
|
|
48
|
+
(0, child_process_1.exec)('naps2.console --list', (error, stdout, stderr) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
console.error("NAPS2 List Error:", stderr);
|
|
51
|
+
return res.json([]);
|
|
52
|
+
}
|
|
53
|
+
const devices = stdout.split('\n').filter(line => line.trim().length > 0);
|
|
54
|
+
res.json({ devices });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else if (engine === 'sane') {
|
|
58
|
+
(0, child_process_1.exec)('scanimage -L', (error, stdout, stderr) => {
|
|
59
|
+
if (error) {
|
|
60
|
+
console.error("SANE List Error:", stderr);
|
|
61
|
+
return res.json([]);
|
|
62
|
+
}
|
|
63
|
+
// scanimage -L output: "device `epsonds:libusb:002:003' is a Epson DS-530 II"
|
|
64
|
+
// We want to return a friendly name or the ID.
|
|
65
|
+
const devices = stdout.split('\n')
|
|
66
|
+
.filter(line => line.includes('is a'))
|
|
67
|
+
.map(line => {
|
|
68
|
+
// Cleanup string
|
|
69
|
+
return line.replace('device `', '').replace(`'`, '');
|
|
70
|
+
});
|
|
71
|
+
res.json({ devices });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const msg = "No scanner software found. Please install NAPS2 (Windows) or SANE (Mac/Linux).";
|
|
76
|
+
console.error(msg);
|
|
77
|
+
res.json({ devices: [], error: msg });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
app.post('/scan', async (req, res) => {
|
|
82
|
+
const scanId = (0, uuid_1.v4)();
|
|
83
|
+
const finalPdfPath = path_1.default.join(TEMP_DIR, `scan_${scanId}.pdf`);
|
|
84
|
+
getScannerEngine((engine) => {
|
|
85
|
+
if (!engine) {
|
|
86
|
+
return res.status(500).json({ error: "No scanner software installed (NAPS2 or SANE)." });
|
|
87
|
+
}
|
|
88
|
+
if (engine === 'naps2') {
|
|
89
|
+
const cmd = `naps2.console -o "${finalPdfPath}" -v`;
|
|
90
|
+
console.log(`Scanning with NAPS2: ${cmd}`);
|
|
91
|
+
(0, child_process_1.exec)(cmd, (error, stdout, stderr) => {
|
|
92
|
+
if (error) {
|
|
93
|
+
console.error(`NAPS2 Error: ${error.message}`);
|
|
94
|
+
return res.status(500).json({ error: "Scan failed", details: stderr });
|
|
95
|
+
}
|
|
96
|
+
if (fs_1.default.existsSync(finalPdfPath)) {
|
|
97
|
+
res.sendFile(finalPdfPath, () => {
|
|
98
|
+
fs_1.default.unlink(finalPdfPath, (err) => { if (err)
|
|
99
|
+
console.error("Cleanup error:", err); });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
res.status(500).json({ error: "Scan completed but PDF not found." });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (engine === 'sane') {
|
|
108
|
+
// SANE flow: scanimage -> tiff -> sips -> pdf
|
|
109
|
+
const tempTiffPath = path_1.default.join(TEMP_DIR, `scan_${scanId}.tiff`);
|
|
110
|
+
// Default to batch access or single scan. `scanimage --format=tiff > output.tiff`
|
|
111
|
+
const cmd = `scanimage --format=tiff --mode Color --resolution 300 > "${tempTiffPath}"`;
|
|
112
|
+
console.log(`Scanning with SANE: ${cmd}`);
|
|
113
|
+
(0, child_process_1.exec)(cmd, (error, stdout, stderr) => {
|
|
114
|
+
if (error) {
|
|
115
|
+
// scanimage writes progress to stderr, so it might not be a real error unless exit code != 0.
|
|
116
|
+
// But exec gives error on non-zero exit.
|
|
117
|
+
console.error(`SANE Error: ${error.message}`);
|
|
118
|
+
return res.status(500).json({ error: "Scan failed", details: stderr });
|
|
119
|
+
}
|
|
120
|
+
// Convert TIFF to PDF using Mac's 'sips' or ImageMagick 'convert'
|
|
121
|
+
// Since we are targeting Mac fallback, we use 'sips'
|
|
122
|
+
const convertCmd = `sips -s format pdf "${tempTiffPath}" --out "${finalPdfPath}"`;
|
|
123
|
+
(0, child_process_1.exec)(convertCmd, (cErr, cOut, cStderr) => {
|
|
124
|
+
// Cleanup TIFF immediately
|
|
125
|
+
if (fs_1.default.existsSync(tempTiffPath))
|
|
126
|
+
fs_1.default.unlinkSync(tempTiffPath);
|
|
127
|
+
if (cErr) {
|
|
128
|
+
console.error(`Conversion Error: ${cStderr}`);
|
|
129
|
+
return res.status(500).json({ error: "Image conversion failed" });
|
|
130
|
+
}
|
|
131
|
+
if (fs_1.default.existsSync(finalPdfPath)) {
|
|
132
|
+
res.sendFile(finalPdfPath, () => {
|
|
133
|
+
fs_1.default.unlink(finalPdfPath, (err) => { if (err)
|
|
134
|
+
console.error("Cleanup error:", err); });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
res.status(500).json({ error: "Conversion completed but PDF not found." });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
if (require.main === module) {
|
|
146
|
+
app.listen(PORT, '127.0.0.1', () => {
|
|
147
|
+
console.log(`Scan2Form Bridge running at http://127.0.0.1:${PORT}`);
|
|
148
|
+
console.log(`Open Example: http://127.0.0.1:${PORT}/example/index.html`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class Scan2Form {
|
|
2
|
+
constructor(bridgeUrl = 'http://127.0.0.1:3000') {
|
|
3
|
+
this.bridgeUrl = bridgeUrl;
|
|
4
|
+
}
|
|
5
|
+
// Rule 5.1: Detect Bridge
|
|
6
|
+
async isAvailable() {
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(`${this.bridgeUrl}/health`);
|
|
9
|
+
return res.ok;
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// List available scanners
|
|
16
|
+
async getDevices() {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${this.bridgeUrl}/devices`);
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
return { devices: [], error: res.statusText };
|
|
21
|
+
const data = await res.json();
|
|
22
|
+
return { devices: data.devices || [], error: data.error };
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return { devices: [], error: "Bridge unreachable" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Rule 5.2 & 5.3: Trigger Scan & Receive Blob
|
|
29
|
+
async scanToInput(inputId) {
|
|
30
|
+
const inputElement = document.getElementById(inputId);
|
|
31
|
+
if (!inputElement)
|
|
32
|
+
throw new Error("Input element not found");
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`${this.bridgeUrl}/scan`, { method: 'POST' });
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error("Scan failed or cancelled at device");
|
|
37
|
+
const blob = await response.blob();
|
|
38
|
+
// Rule 5.4: Inject into DataTransfer
|
|
39
|
+
const file = new File([blob], `scanned_doc_${Date.now()}.pdf`, { type: 'application/pdf' });
|
|
40
|
+
const dataTransfer = new DataTransfer();
|
|
41
|
+
dataTransfer.items.add(file);
|
|
42
|
+
inputElement.files = dataTransfer.files;
|
|
43
|
+
// Trigger change event so frameworks (React/Vue) detect the update
|
|
44
|
+
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
|
|
45
|
+
return { success: true, file: file };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error("Scan2Form Error:", error);
|
|
49
|
+
// Alerting might be annoying in a library, maybe optional? Leaving as is for now but usually libraries shouldn't alert.
|
|
50
|
+
// alert("Ensure Scan2Form Bridge is running!");
|
|
51
|
+
return { success: false, error: error };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Scan2Form = void 0;
|
|
4
|
+
class Scan2Form {
|
|
5
|
+
constructor(bridgeUrl = 'http://127.0.0.1:3000') {
|
|
6
|
+
this.bridgeUrl = bridgeUrl;
|
|
7
|
+
}
|
|
8
|
+
// Rule 5.1: Detect Bridge
|
|
9
|
+
async isAvailable() {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${this.bridgeUrl}/health`);
|
|
12
|
+
return res.ok;
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// List available scanners
|
|
19
|
+
async getDevices() {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`${this.bridgeUrl}/devices`);
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
return { devices: [], error: res.statusText };
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
return { devices: data.devices || [], error: data.error };
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return { devices: [], error: "Bridge unreachable" };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Rule 5.2 & 5.3: Trigger Scan & Receive Blob
|
|
32
|
+
async scanToInput(inputId) {
|
|
33
|
+
const inputElement = document.getElementById(inputId);
|
|
34
|
+
if (!inputElement)
|
|
35
|
+
throw new Error("Input element not found");
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`${this.bridgeUrl}/scan`, { method: 'POST' });
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error("Scan failed or cancelled at device");
|
|
40
|
+
const blob = await response.blob();
|
|
41
|
+
// Rule 5.4: Inject into DataTransfer
|
|
42
|
+
const file = new File([blob], `scanned_doc_${Date.now()}.pdf`, { type: 'application/pdf' });
|
|
43
|
+
const dataTransfer = new DataTransfer();
|
|
44
|
+
dataTransfer.items.add(file);
|
|
45
|
+
inputElement.files = dataTransfer.files;
|
|
46
|
+
// Trigger change event so frameworks (React/Vue) detect the update
|
|
47
|
+
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
|
|
48
|
+
return { success: true, file: file };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error("Scan2Form Error:", error);
|
|
52
|
+
// Alerting might be annoying in a library, maybe optional? Leaving as is for now but usually libraries shouldn't alert.
|
|
53
|
+
// alert("Ensure Scan2Form Bridge is running!");
|
|
54
|
+
return { success: false, error: error };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.Scan2Form = Scan2Form;
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scan2form",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local offline bridge allowing web browsers to access physical scanners (WIA, TWAIN, SANE).",
|
|
5
|
+
"main": "dist/scanner-client.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node dist/bridge-server.js",
|
|
8
|
+
"example": "npm run build && npm start",
|
|
9
|
+
"dev": "nodemon src/bridge-server.ts",
|
|
10
|
+
"build": "tsc && tsc src/scanner-client.ts --target es2020 --module es2020 --moduleResolution node --outDir dist/esm",
|
|
11
|
+
"test": "jest",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"scan2form-server": "dist/bridge-server.js"
|
|
16
|
+
},
|
|
17
|
+
"types": "./dist/scanner-client.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"scanner",
|
|
25
|
+
"twain",
|
|
26
|
+
"wia",
|
|
27
|
+
"sane",
|
|
28
|
+
"web-scanning",
|
|
29
|
+
"local-bridge"
|
|
30
|
+
],
|
|
31
|
+
"author": "jodeveloper8",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"cors": "^2.8.5",
|
|
35
|
+
"express": "^4.18.2",
|
|
36
|
+
"uuid": "^9.0.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/cors": "^2.8.17",
|
|
40
|
+
"@types/express": "^4.17.21",
|
|
41
|
+
"@types/jest": "^30.0.0",
|
|
42
|
+
"@types/node": "^20.10.6",
|
|
43
|
+
"@types/supertest": "^6.0.3",
|
|
44
|
+
"@types/uuid": "^9.0.7",
|
|
45
|
+
"jest": "^30.2.0",
|
|
46
|
+
"nodemon": "^3.0.2",
|
|
47
|
+
"supertest": "^7.2.2",
|
|
48
|
+
"ts-jest": "^29.4.6",
|
|
49
|
+
"ts-node": "^10.9.2",
|
|
50
|
+
"typescript": "^5.3.3"
|
|
51
|
+
}
|
|
52
|
+
}
|