Build a JavaScript Web-Based Document Scanner with PDF and Word Export
In today’s digital world, we often need to convert, merge, and edit documents from various sources. Whether you’re working with PDFs, Word documents, images from your phone, or scanned documents from a physical scanner, having a unified tool that handles all these formats is invaluable.
In this tutorial, we’ll build a free, web-based document converter that runs entirely in your browser, ensuring your files remain private and secure. Most features are completely free, with only the advanced scanner integration requiring a license for use.
What you’ll build: A JavaScript web-based document scanner that imports PDFs, Word files, images, and webcam captures, edits them with crop/rotate/filters, and exports a merged document as PDF or Word — all locally in the browser.
Key Takeaways
- This tutorial demonstrates how to build a fully browser-based document converter that accepts PDF, DOCX, images, TIFF, and physical TWAIN/WIA/SANE scanner input.
- Dynamic Web TWAIN’s local REST API (
http://127.0.0.1:18622) is the integration point for physical scanner support; all other document operations run license-free in the browser. - IndexedDB stores page blobs and full undo/redo history persistently on the client, enabling offline-capable document assembly without any server uploads.
- The export pipeline uses the browser’s native print dialog for PDF (correct Unicode rendering) and
html-docx-jsfor Word generation.
Common Developer Questions
- How do I build a web-based document scanner that exports to PDF using JavaScript?
- How do I connect a physical TWAIN or WIA scanner to a web application?
- How do I merge scanned documents with uploaded PDFs and images in the browser?
Demo: Free Online Document Converter with Scanner Support
Try the Live Demo
https://yushulx.me/javascript-barcode-qr-code-scanner/examples/document_converter/
Why Use a Browser-Only JavaScript Document Scanner?
Real-World Use Cases
- Home Office Document Management
- Merge multiple scanned receipts into a single PDF for expense reports
- Convert Word documents to PDF before submitting applications
- Combine photos of handwritten notes with typed documents
- Remote Work & Collaboration
- Quickly edit and merge documents without uploading to cloud services (privacy-focused)
- Create document packages from mixed sources (camera, scanner, files)
- Edit Word documents directly in the browser without Microsoft Office
- Education
- Students can merge lecture notes, screenshots, and scanned materials
- Teachers can create combined study materials from various sources
- No software installation required on school computers
- Small Business
- Process invoices and receipts from physical documents using a scanner
- Create professional PDF portfolios from mixed media
- Edit and merge contracts without expensive software
Key Advantages of a Browser-Only Architecture
- 100% Browser-Based: No server uploads, no privacy concerns
- Free Core Features: Document viewing, editing, merging, and export
- Multi-Format Support: PDF, Word, images, TIFF, text files
- Physical Scanner Support: Optional integration with TWAIN/WIA/SANE/ICA scanners
- Offline Capable: Works without internet after initial load
What’s Free vs. What Requires a License
Completely Free Features (No License Required)
- File upload and viewing (PDF, DOCX, images, TIFF, TXT)
- Camera capture
- Document editing (text and images)
- Image editing (crop, rotate, filters, resize)
- Drag-and-drop reordering
- Merge documents
- Export to PDF and Word
- Undo/Redo
- Thumbnail navigation
- Zoom and pan
Prerequisites
Get a 30-day free trial license for Dynamic Web TWAIN to enable physical scanner integration. All other features in this tutorial are completely free.
Libraries and Technologies Used
We’ll use these powerful libraries:
| Library | Purpose |
|---|---|
| PDF.js | PDF rendering |
| Mammoth.js | DOCX to HTML conversion |
| jsPDF | PDF generation |
| html-docx-js | Word document generation |
| html2canvas | HTML to image rendering |
| UTIF.js | TIFF decoding |
| Dynamic Web TWAIN | Scanner integration |
Step 1: Set Up the HTML Structure
Create index.html with the basic structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Reader & Editor</title>
<link rel="icon" type="image/png" href="favicon.png">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="lib/fontawesome.min.css">
<!-- Core Libraries -->
<script src="lib/jquery.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="lib/mammoth.browser.min.js"></script>
<script src="lib/html-docx.js"></script>
<!-- PDF & Image Tools -->
<script src="lib/pdf.min.js"></script>
<script>window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js';</script>
<script src="lib/jspdf.umd.min.js"></script>
<script src="lib/html2canvas.min.js"></script>
<script src="lib/UTIF.js"></script>
</head>
<body>
<header>
<h1>Document Converter & Gallery</h1>
<p>Convert Word/PDF/Images, Merge, and Save.</p>
</header>
<main>
<!-- Controls toolbar -->
<div id="controls">
<div class="controls-group">
<label for="file-input" class="button btn-primary btn-icon-only" title="Add Files">
<i class="fas fa-file-upload"></i>
</label>
<button id="camera-button" class="button btn-primary btn-icon-only" title="Open Camera">
<i class="fas fa-camera"></i>
</button>
<button id="scanner-button" class="button btn-primary btn-icon-only" title="Scan from Scanner">
<i class="fas fa-print"></i>
</button>
<button id="add-page-button" class="button btn-primary btn-icon-only" title="Create Blank Page">
<i class="fas fa-file-medical"></i>
</button>
<input type="file" id="file-input" accept=".docx,.pdf,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.txt" multiple>
</div>
<div class="controls-group">
<button id="save-pdf-button" class="button btn-success btn-icon-only" title="Save as PDF">
<i class="fas fa-file-pdf"></i>
</button>
<button id="save-word-button" class="button btn-success btn-icon-only" title="Save as Word">
<i class="fas fa-file-word"></i>
</button>
</div>
</div>
<!-- Workspace with thumbnails and viewer -->
<div id="workspace">
<aside id="thumbnails-panel"></aside>
<section id="viewer-panel">
<div id="scroll-wrapper">
<div id="large-view-container"></div>
</div>
</section>
</div>
</main>
<!-- Loading Overlay for DOCX Processing -->
<div id="loading-overlay" class="modal-overlay" style="display: none; z-index: 2000;">
<div class="loading-content" style="text-align: center; color: white;">
<i class="fas fa-spinner fa-spin fa-3x"></i>
<p style="margin-top: 15px; font-size: 1.2rem;">Processing Document...</p>
</div>
</div>
<script src="app.js" defer></script>
</body>
</html>
Key Elements:
- File input for uploading files
- Camera button for capturing images
- Scanner button for physical document scanning
- Thumbnails panel for navigation
- Viewer panel for displaying selected page
- Loading overlay for feedback during DOCX processing
Step 2: Style the Interface with CSS
Create style.css for a modern, responsive interface:
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f7f9;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background-color: #fff;
border-bottom: 1px solid #dde3e8;
padding: 20px 0;
text-align: center;
}
header h1 {
margin: 0;
font-size: 1.8rem;
color: #007bff;
}
#controls {
display: flex;
align-items: center;
gap: 12px;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
}
.button {
padding: 10px 16px;
font-size: 0.95rem;
color: #fff;
background-color: #007bff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.2s ease;
}
.button:hover {
background-color: #0056b3;
}
#workspace {
display: flex;
flex: 1;
overflow: hidden;
}
#thumbnails-panel {
width: 200px;
background-color: #f9f9f9;
overflow-y: auto;
padding: 10px;
}
.thumbnail {
background: white;
margin-bottom: 10px;
padding: 5px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.thumbnail.active {
border-color: #007bff;
}
#viewer-panel {
flex: 1;
position: relative;
overflow: hidden;
}
/* Loading Overlay */
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
text-align: center;
color: white;
}
Step 3: Initialize IndexedDB for Local Page Storage
We’ll use IndexedDB to store pages locally, ensuring data persists even after page refresh:
const dbName = 'DocScannerDB';
const storeName = 'images';
let db;
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onerror = (e) => reject(e);
request.onsuccess = (e) => {
db = e.target.result;
resolve(db);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: 'id' });
}
};
});
}
// Initialize on page load
initDB().then(() => {
loadSavedPages();
}).catch(console.error);
Why IndexedDB?
- Stores large binary data (images, documents)
- Asynchronous (doesn’t block UI)
- Works offline
- No size limits like localStorage
Step 4: Handle File Upload and Multi-Format Processing
Handle multiple file formats:
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (event) => {
handleFiles(Array.from(event.target.files));
fileInput.value = ''; // Reset input
});
async function handleFiles(files) {
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'pdf') {
await handlePDF(file);
} else if (ext === 'docx') {
await handleDOCX(file);
} else if (ext === 'txt') {
await handleTXT(file);
} else if (['tiff', 'tif'].includes(ext)) {
await handleTIFF(file);
} else if (['jpg', 'jpeg', 'png', 'bmp', 'webp'].includes(ext)) {
await handleImage(file);
}
}
}
Process PDF Files with PDF.js
async function handlePDF(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
await addPage({
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width: viewport.width,
height: viewport.height,
sourceFile: `${file.name} (Page ${i})`
});
}
}
Process DOCX Files with Mammoth.js
async function handleDOCX(file) {
const loadingOverlay = document.getElementById('loading-overlay');
loadingOverlay.style.display = 'flex';
try {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer: arrayBuffer });
const html = result.value;
// Generate thumbnail
const tempContainer = document.createElement('div');
tempContainer.style.width = '800px';
tempContainer.style.background = 'white';
tempContainer.style.padding = '40px';
tempContainer.style.position = 'absolute';
tempContainer.style.left = '-9999px';
tempContainer.innerHTML = html;
document.body.appendChild(tempContainer);
let thumbnailDataUrl;
try {
const canvas = await html2canvas(tempContainer, {
scale: 0.5,
height: 1100,
windowHeight: 1100,
useCORS: true,
ignoreElements: (element) => {
return element.tagName === 'VIDEO' || element.id === 'camera-overlay';
}
});
thumbnailDataUrl = canvas.toDataURL('image/jpeg', 0.8);
} catch (e) {
console.error("Thumbnail generation failed:", e);
thumbnailDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
} finally {
document.body.removeChild(tempContainer);
}
await addPage({
dataUrl: thumbnailDataUrl,
width: 800,
height: 1100,
sourceFile: file.name,
htmlContent: html // Store for editing
});
} finally {
loadingOverlay.style.display = 'none';
}
}
Key Feature: We show a loading animation while processing large DOCX files, improving user experience.
Step 5: Capture Images from the Device Camera
Add the ability to capture images directly from the device camera:
const cameraButton = document.getElementById('camera-button');
const cameraOverlay = document.getElementById('camera-overlay');
const cameraVideo = document.getElementById('camera-video');
const captureBtn = document.getElementById('capture-btn');
const closeCameraBtn = document.getElementById('close-camera-btn');
let mediaStream = null;
cameraButton.addEventListener('click', async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ video: true });
cameraVideo.srcObject = mediaStream;
cameraOverlay.style.display = 'flex';
} catch (err) {
alert('Camera access denied or not available.');
}
});
closeCameraBtn.addEventListener('click', stopCamera);
function stopCamera() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
}
cameraOverlay.style.display = 'none';
}
captureBtn.addEventListener('click', async () => {
const canvas = document.createElement('canvas');
canvas.width = cameraVideo.videoWidth;
canvas.height = cameraVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
await addPage({
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width: canvas.width,
height: canvas.height,
sourceFile: `Camera Capture ${new Date().toLocaleTimeString()}`
});
selectPage(pages.length - 1);
});
Step 6: Connect a Physical Scanner via Dynamic Web TWAIN
Now let’s add the optional scanner feature using Dynamic Web TWAIN. This is the only paid feature in the entire application.
6.1: Build the Scanner Selection UI
<!-- Scanner Modal -->
<div id="scanner-modal" class="modal" style="display:none;">
<div class="modal-content">
<div class="modal-header">
<h3>Scan from Scanner</h3>
</div>
<div class="modal-body">
<div class="control-group">
<label>License Key:</label>
<input type="text" id="dwt-license" placeholder="Enter Dynamic Web TWAIN license">
<small>Get a <a href="https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform" target="_blank">free trial license</a>.</small>
</div>
<div class="control-group">
<label>Select Scanner:</label>
<select id="scanner-source">
<option value="">No scanners found</option>
</select>
<button id="refresh-scanners" class="button btn-secondary">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="control-group">
<label>Resolution (DPI):</label>
<select id="scan-resolution">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="300">300</option>
<option value="600">600</option>
</select>
</div>
<div class="control-group">
<input type="checkbox" id="scan-adf">
<label for="scan-adf">Use ADF (Feeder)</label>
</div>
</div>
<div class="modal-footer">
<button id="scanner-cancel" class="button btn-secondary">Cancel</button>
<button id="scanner-scan" class="button btn-primary">Scan Now</button>
</div>
</div>
</div>
6.2: Implement Scanner Job Polling Logic
const scannerButton = document.getElementById('scanner-button');
const scannerModal = document.getElementById('scanner-modal');
const host = 'http://127.0.0.1:18622'; // Local DWT service
scannerButton.addEventListener('click', () => {
openModal(scannerModal);
fetchScanners();
});
async function fetchScanners() {
try {
const response = await fetch(`${host}/api/device/scanners`);
const data = await response.json();
const select = document.getElementById('scanner-source');
select.innerHTML = '';
if (data.length === 0) {
select.innerHTML = '<option>No scanners found</option>';
} else {
data.forEach(scanner => {
const option = document.createElement('option');
option.value = JSON.stringify(scanner);
option.textContent = scanner.name;
select.appendChild(option);
});
}
} catch (error) {
console.error('Scanner fetch error:', error);
alert('Scanner service not running. Please install Dynamic Web TWAIN service.');
}
}
document.getElementById('scanner-scan').addEventListener('click', async () => {
const scanner = document.getElementById('scanner-source').value;
const license = document.getElementById('dwt-license').value.trim();
if (!license) {
alert('Please enter a license key.');
return;
}
const parameters = {
license: license,
device: JSON.parse(scanner).device,
config: {
PixelType: 2,
Resolution: parseInt(document.getElementById('scan-resolution').value),
IfFeederEnabled: document.getElementById('scan-adf').checked
}
};
try {
// Create scan job
const jobResponse = await fetch(`${host}/api/device/scanners/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parameters)
});
const jobData = await jobResponse.json();
const jobId = jobData.jobId;
// Poll for results
let imageId = '';
while (true) {
await new Promise(resolve => setTimeout(resolve, 1000));
const statusResponse = await fetch(`${host}/api/device/scanners/jobs/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.state === 'Transferred') {
imageId = statusData.imageId;
break;
} else if (statusData.state === 'Failed') {
throw new Error('Scan failed');
}
}
// Get scanned images
const imageResponse = await fetch(`${host}/api/buffer/${imageId}`);
const imageData = await imageResponse.json();
for (const img of imageData.images) {
const dataUrl = `data:image/png;base64,${img.data}`;
await addPage({
dataUrl: dataUrl,
width: img.width,
height: img.height,
sourceFile: `Scanned Document ${new Date().toLocaleString()}`
});
}
closeModal();
alert(`Successfully scanned ${imageData.images.length} page(s)!`);
} catch (error) {
console.error('Scan error:', error);
alert('Scanning failed. Check console for details.');
}
});
Step 7: Add Pages to the Document Gallery
Implement the core page management system:
let pages = [];
let currentPageIndex = -1;
async function addPage(pageData) {
const id = Date.now() + Math.random();
const blob = dataURLtoBlob(pageData.dataUrl);
const thumbnailDataUrl = await createThumbnail(pageData.dataUrl);
const pageObject = {
id,
blob,
originalBlob: blob,
history: [blob],
historyIndex: 0,
width: pageData.width,
height: pageData.height,
sourceFile: pageData.sourceFile,
thumbnailDataUrl: thumbnailDataUrl,
htmlContent: pageData.htmlContent
};
await storeImageInDB(pageObject);
pages.push({
id,
width: pageData.width,
height: pageData.height,
sourceFile: pageData.sourceFile,
thumbnailDataUrl: thumbnailDataUrl,
historyIndex: 0,
historyLength: 1,
htmlContent: pageData.htmlContent
});
renderAllThumbnails();
}
function renderAllThumbnails() {
const thumbnailsPanel = document.getElementById('thumbnails-panel');
thumbnailsPanel.innerHTML = '';
pages.forEach((page, index) => {
const div = document.createElement('div');
div.className = 'thumbnail';
div.dataset.index = index;
div.onclick = () => selectPage(index);
const img = document.createElement('img');
img.src = page.thumbnailDataUrl;
const num = document.createElement('div');
num.className = 'thumbnail-number';
num.textContent = index + 1;
div.appendChild(img);
div.appendChild(num);
thumbnailsPanel.appendChild(div);
});
}
function createThumbnail(dataUrl, maxWidth = 300) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = maxWidth / img.width;
canvas.width = maxWidth;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.src = dataUrl;
});
}
Step 8: Implement Image Editing (Rotate, Filters)
Add crop, rotate, and filter capabilities:
// Rotate
rotateBtn.addEventListener('click', () => {
currentRotation = 0;
rotateSlider.value = 0;
openModal(rotateModal);
});
rotateApply.addEventListener('click', async () => {
const img = document.getElementById('large-image');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const rad = currentRotation * Math.PI / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));
const w = img.naturalWidth;
const h = img.naturalHeight;
canvas.width = w * cos + h * sin;
canvas.height = w * sin + h * cos;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);
await saveEditedImage(canvas.toDataURL('image/jpeg', 0.9));
closeModal();
});
// Filters
filterApply.addEventListener('click', async () => {
const img = document.getElementById('large-image');
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const brightness = parseInt(brightnessSlider.value);
const contrast = parseInt(contrastSlider.value);
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < data.length; i += 4) {
// Apply brightness and contrast
data[i] = factor * (data[i] - 128) + 128 + brightness;
data[i+1] = factor * (data[i+1] - 128) + 128 + brightness;
data[i+2] = factor * (data[i+2] - 128) + 128 + brightness;
// Apply grayscale if selected
if (filterType.value === 'grayscale') {
const gray = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
data[i] = data[i+1] = data[i+2] = gray;
}
}
ctx.putImageData(imageData, 0, 0);
await saveEditedImage(canvas.toDataURL('image/jpeg', 0.9));
closeModal();
});
Step 9: Export the Document to PDF and Word
Export to PDF via the Browser Print API
We use the browser’s print functionality because jsPDF requires large custom font files to support non-Latin characters (like Chinese/Japanese) correctly. The system print dialog ensures all characters are rendered correctly and remain editable/selectable.
savePdfButton.addEventListener('click', async () => {
if (pages.length === 0) return alert('No pages to save!');
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.width = '0';
iframe.style.height = '0';
document.body.appendChild(iframe);
let htmlContent = '<!DOCTYPE html><html><head><title>Document</title>';
htmlContent += `
<style>
@page { size: A4; margin: 20mm; }
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
img { max-width: 100%; }
.page-break { page-break-after: always; }
</style>`;
htmlContent += '</head><body>';
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (page.htmlContent) {
htmlContent += `<div class="page-content">${page.htmlContent}</div>`;
} else {
const blob = await getImageFromDB(page.id);
const dataUrl = await blobToDataURL(blob);
htmlContent += `<img src="${dataUrl}" alt="Page ${i+1}">`;
}
if (i < pages.length - 1) {
htmlContent += '<div class="page-break"></div>';
}
}
htmlContent += '</body></html>';
const doc = iframe.contentWindow.document;
doc.open();
doc.write(htmlContent);
doc.close();
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow.print();
setTimeout(() => document.body.removeChild(iframe), 100);
}, 500);
};
});
Export to Word with html-docx-js
saveWordButton.addEventListener('click', async () => {
if (pages.length === 0) return alert('No pages to save!');
let htmlContent = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Document</title></head><body>';
for (const page of pages) {
if (page.htmlContent) {
htmlContent += page.htmlContent;
} else {
const blob = await getImageFromDB(page.id);
const dataUrl = await blobToDataURL(blob);
htmlContent += `<p><img src="${dataUrl}" /></p>`;
}
}
htmlContent += '</body></html>';
const converted = htmlDocx.asBlob(htmlContent);
const link = document.createElement('a');
link.href = URL.createObjectURL(converted);
link.download = 'combined_document.docx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
Note: To avoid garbled characters in Word export, ensure all text is properly encoded in UTF-8.
Step 10: Implement Undo and Redo History
Implement history tracking for image edits:
async function saveEditedImage(dataUrl) {
const page = pages[currentPageIndex];
const blob = dataURLtoBlob(dataUrl);
const thumb = await createThumbnail(dataUrl);
const img = new Image();
img.onload = async () => {
// Truncate future history if we're not at the end
const historyIndex = page.historyIndex !== undefined ? page.historyIndex : 0;
// Get existing history
const existingData = await getImageFromDB(page.id);
let history = existingData.history || [existingData.originalBlob];
// Remove future states
history = history.slice(0, historyIndex + 1);
// Add new state
history.push(blob);
// Update DB
await storeImageInDB({
id: page.id,
blob: blob,
originalBlob: existingData.originalBlob,
history: history,
historyIndex: history.length - 1,
width: img.width,
height: img.height,
sourceFile: page.sourceFile,
thumbnailDataUrl: thumb
});
// Update in-memory
page.width = img.width;
page.height = img.height;
page.thumbnailDataUrl = thumb;
page.historyIndex = history.length - 1;
page.historyLength = history.length;
renderAllThumbnails();
renderLargeView();
updateUndoRedoButtons();
};
img.src = dataUrl;
}
undoBtn.addEventListener('click', async () => {
const page = pages[currentPageIndex];
if (page.historyIndex > 0) {
await loadHistoryState(page, page.historyIndex - 1);
}
});
redoBtn.addEventListener('click', async () => {
const page = pages[currentPageIndex];
if (page.historyIndex < page.historyLength - 1) {
await loadHistoryState(page, page.historyIndex + 1);
}
});
Run and Test the Application Locally
-
Run Local Server:
python -m http.server 8000 -
Open Browser: Navigate to
http://localhost:8000
Common Issues and Edge Cases
- DOCX with complex layouts renders incorrectly: Mammoth.js converts DOCX to semantic HTML but drops Word-specific formatting like custom fonts and complex tables. For best results, use simple paragraph-based documents. If thumbnail generation fails, a 1×1 pixel placeholder prevents a crash instead of throwing.
- Scanner service not detected: The Dynamic Web TWAIN REST API runs locally at
http://127.0.0.1:18622. IffetchScanners()throws a network error, the user must first install the Dynamic Web TWAIN desktop service on their machine. - PDF export garbles non-Latin text: Directly using
jsPDFto handle Chinese/Japanese/Korean characters requires bundling a large CJK font file. This tutorial uses the browser’s native print dialog instead, which renders all Unicode correctly without extra font assets.
Frequently Asked Questions
How do I build a JavaScript web-based document scanner that exports to PDF?
Use PDF.js to render uploaded PDFs to canvas, navigator.mediaDevices.getUserMedia for webcam capture, and the Dynamic Web TWAIN REST API (http://127.0.0.1:18622) for physical scanner input. Assemble all pages in an IndexedDB store and trigger the browser’s print dialog to export to PDF with correct Unicode rendering.
How do I connect a TWAIN or WIA scanner to a web application without a plugin?
Install the Dynamic Web TWAIN desktop service, which exposes a local REST API at http://127.0.0.1:18622. Call GET /api/device/scanners to list connected devices, then POST /api/device/scanners/jobs to initiate a scan job and poll for results. No browser plugin or NPAPI extension is required.
How do I merge scanned documents with uploaded PDFs and images in the browser?
Store each page (from any source) as a blob in IndexedDB with a shared page array. When exporting, iterate the array in order — render HTML content for DOCX pages and data URLs for image/PDF pages — and write them into a single print-ready HTML document or a Word file using html-docx-js.
Source Code
https://github.com/yushulx/javascript-barcode-qr-code-scanner/tree/main/examples/document_converter