DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

Building a Free Web-Based Document Converter with Scanner Support

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.

Demo: Free Online Document Converter with Scanner Support

Live Demo

https://yushulx.me/javascript-barcode-qr-code-scanner/examples/document_converter/

Why This Tool is Useful

Real-World Usage Scenarios

  1. 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
  2. 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
  3. 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
  4. 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

  • 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

Free vs. Paid Features

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

Scanner Integration

The Dynamic Web TWAIN trial license is required.

Technologies Overview

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: 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>
Enter fullscreen mode Exit fullscreen mode

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: CSS Styling

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;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: IndexedDB for Local 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);
Enter fullscreen mode Exit fullscreen mode

Why IndexedDB?

  • Stores large binary data (images, documents)
  • Asynchronous (doesn't block UI)
  • Works offline
  • No size limits like localStorage

Step 4: File Upload and 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Processing PDF Files

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})`
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Processing DOCX Files

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';
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Feature: We show a loading animation while processing large DOCX files, improving user experience.

Step 5: Camera Capture

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);
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Scanner Integration with 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: Add Scanner HTML Modal

<!-- 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>
Enter fullscreen mode Exit fullscreen mode

6.2: Scanner JavaScript 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.');
    }
});
Enter fullscreen mode Exit fullscreen mode

Step 7: Adding Pages to the 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;
    });
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Image Editing Features

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();
});
Enter fullscreen mode Exit fullscreen mode

Step 9: Export to PDF and Word

Export to PDF

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);
    };
});
Enter fullscreen mode Exit fullscreen mode

Export to Word

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);
});
Enter fullscreen mode Exit fullscreen mode

Note: To avoid garbled characters in Word export, ensure all text is properly encoded in UTF-8.

Step 10: Undo and Redo

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

Testing Your Application

  1. Run Local Server:

    python -m http.server 8000
    
  2. Open Browser: Navigate to http://localhost:8000

    free web-based document converter

Source Code

https://github.com/yushulx/javascript-barcode-qr-code-scanner/tree/main/examples/document_converter

Top comments (0)