How to Build a Client-Side Online Image Converter in JavaScript

Dynamic Web TWAIN is a document-scanning SDK which makes it possible to access document scanners in the browser. It comes with the support of various image formats. We can use it to create an on-premises online image converter. You can press the button above to have a try. It has the following features:

  • Supported input formats: BMP, JPEG, PNG, TIFF, PDF, ZIP
  • Supported output formats: JPEG, PNG, TIFF, PDF, ZIP
  • Batch conversion
  • Client-side processing which does not require a server

In the following, we are going to talk about how it is made.

What you’ll build: A client-side online image converter using Dynamic Web TWAIN that batch-converts between BMP, JPEG, PNG, TIFF, and PDF entirely in the browser — no server upload required.

Key Takeaways

  • Dynamic Web TWAIN’s LoadImageFromBinary and ConvertToBlob APIs enable browser-side image format conversion without any server-side processing.
  • The converter supports BMP, JPEG, PNG, TIFF, and PDF as inputs, and JPEG, PNG, TIFF, and PDF as outputs, including batch conversion and ZIP download.
  • Multi-page formats (PDF and TIFF) are handled automatically — individual pages are split when converting to single-page formats like JPEG or PNG.
  • The entire converter can be packaged as a reusable TypeScript library with Vite and published to npm.

Common Developer Questions

  • How do I convert images between formats in the browser using JavaScript without a server?
  • How can I batch-convert multiple images to PDF or TIFF client-side?
  • How do I build a JavaScript image converter that handles multi-page PDF and TIFF files?

Prerequisites

Step 1: Create a New Vite Project

Create a new project with Vite and the typescript template:

npm create vite@latest ImageConverter -- --template vanilla-ts

Step 2: Install Dynamic Web TWAIN and JSZip

Install Dynamic Web TWAIN and JSZip as project dependencies:

npm install dwt@18.0.2 jszip

Step 3: Create the Image Converter Class

Next, create an image-converter.ts file for the ImageConverter class.

export class ImageConverter {
  constructor() {}
}

Initialize the Dynamic Web TWAIN Instance

In the constructor, initialize an instance of Dynamic Web TWAIN. A config param is used in the constructor for passing related params like the license. You can apply for a license here.

export interface Config {
  license?:string;
}

export class ImageConverter {
  private DWObject!:WebTwain;
  constructor(config?:Config) {
    if (config) {
      if (config.license) {
        Dynamsoft.DWT.ProductKey = config.license;
      }
    }
    this.initDWT();
  }
  
  initDWT(){
    Dynamsoft.DWT.AutoLoad = false;
    Dynamsoft.DWT.Containers = [];
    Dynamsoft.DWT.ResourcesPath = "https://unpkg.com/dwt@18.0.2/dist";
    Dynamsoft.DWT.UseLocalService = false;
    let pThis = this;
    Dynamsoft.DWT.CreateDWTObjectEx(
      {
        WebTwainId: 'dwtcontrol'
      },
      function(obj:WebTwain) {
        pThis.DWObject = obj;
      },
      function(err:string) {
        console.log(err);
      }
    );
  }
}

Add the Image Conversion Function

Next, create a new function for converting an image file. It does the following:

  1. Use Web TWAIN’s LoadImageFromBinary to load an image file into the buffer of Web TWAIN.
  2. Use Web TWAIN’s ConvertToBlob function to convert the image into the desired format.
  3. The function returns an array of image files because PDF or TIFF can have multiple images in one file.

Code:

export enum ImageFormat {
  JPG = 0,
  PNG = 1,
  PDF = 2,
  TIFF = 3
}

export interface ConvertedFile {
  filename:string;
  blob:Blob;
}

export class ImageConverter {
  private DWObject!:WebTwain;
  async convert(file:File,targetFormat:ImageFormat){
    let files:ConvertedFile[] = [];
    this.DWObject.RemoveAllImages();
    await this.loadImageFromFile(file);
    let fileType = 7;
    let extension = "";
    if (targetFormat === ImageFormat.JPG) {
      fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_JPG;
      extension = ".jpg";
    }else if (targetFormat === ImageFormat.PNG) {
      fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG;
      extension = ".png";
    }else if (targetFormat === ImageFormat.PDF) {
      fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF;
      extension = ".pdf";
    }else if (targetFormat === ImageFormat.TIFF) {
      fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_TIF;
      extension = ".tiff";
    }
    if (targetFormat > 1) {
      let blob = await this.getBlob(this.getImageIndices(),fileType);
      files.push({filename:this.getFileNameWithoutExtension(file.name)+extension,blob:blob})
    }else{
      if (this.DWObject.HowManyImagesInBuffer > 1) {
        for (let index = 0; index < this.DWObject.HowManyImagesInBuffer; index++) {
          let blob = await this.getBlob([index],fileType);
          files.push({filename:this.getFileNameWithoutExtension(file.name)+"-"+index+extension,blob:blob})
        }
      }else{
        let blob = await this.getBlob([0],fileType);
        files.push({filename:this.getFileNameWithoutExtension(file.name)+extension,blob:blob})
      }
    }
    return files;
  }
  
  async loadImageFromFile(file:File){
    return new Promise<void>((resolve, reject) => {
      this.DWObject.LoadImageFromBinary(file,
        function(){
          resolve();
        },
        function(_errorCode: number, errorString: string){
          reject(errorString);
        }
      )
    })
  }
  
  getBlob(indices:number[],type:number){
    return new Promise<Blob>((resolve, reject) => {
      this.DWObject.ConvertToBlob(indices,type,
        function(blob:Blob){
          resolve(blob);
        },
        function(_errorCode: number, errorString: string){
          reject(errorString);
        }
      )  
    })
  }
  
  getImageIndices(){
    let indices = [];
    for (let index = 0; index < this.DWObject.HowManyImagesInBuffer; index++) {
      indices.push(index);
    }
    return indices;
  }
  
  getFileNameWithoutExtension(filename:string){
    if (filename.lastIndexOf(".") != -1) {
      return filename.substring(0,filename.lastIndexOf("."));
    }else{
      return filename;
    }
  }
}

Build the File Selection and Conversion UI

For ease of use, we can build a UI for the converter. The UI can be bound to a container.

Here is a screenshot of the final result:

screenshot

  1. Add a container item to the config.

     export interface Config {
       license?:string;
    +  container?:HTMLDivElement;
     }
    
  2. In the constructor, bind the UI elements to the container.

    constructor(config?:Config) {
      if (config) {
        if (config.container) {
          this.container = config.container;
          this.createElements();
        }
        if (config.license) {
          Dynamsoft.DWT.ProductKey = config.license;
        }
      }
      this.initDWT();
    }
       
    createElements(){
      let pThis = this;
      this.files = document.createElement("div");
      let actionsContainer = document.createElement("div");
      actionsContainer.className = styles.actions;
      let chooseFilesContainer = document.createElement("div");
      this.fileInput = document.createElement("input");
      this.fileInput.style.display = "none";
      this.fileInput.multiple = true;
      this.fileInput.accept = ".bmp,.jpeg,.jpg,.png,.pdf,.tiff,.tif,.zip";
      this.fileInput.type = "file";
      this.fileInput.addEventListener("change",async function(){
        pThis.chooseFilesButton.innerText = "Loading...";
        await pThis.appendFiles();
        pThis.listFiles();
        pThis.chooseFilesButton.innerText = "Choose Files";
      })
      this.chooseFilesButton = this.DynamsoftButton("Choose Files");
      this.chooseFilesButton.addEventListener("click",function(){
        pThis.fileInput!.click();
      });
      chooseFilesContainer.appendChild(this.fileInput);
      chooseFilesContainer.appendChild(this.chooseFilesButton);
      actionsContainer.appendChild(chooseFilesContainer);
      this.convertActions = document.createElement("div");
      this.convertActions.className = styles.convertActions;
      this.convertActions.style.display = "none";
      let formatSelector = document.createElement("label");
      formatSelector.innerText = "To:"
      this.formatSelect = document.createElement("select");
      for (const format of ["JPG","PNG","PDF","TIFF"]) {
        this.formatSelect.options.add(new Option(format,format));
      }
      formatSelector.appendChild(this.formatSelect);
      let useZip = document.createElement("label");
      useZip.innerText = "Download as Zip:"
      useZip.style.marginLeft = "10px";
      this.useZipCheckbox = document.createElement("input");
      this.useZipCheckbox.type = "checkbox";
      useZip.appendChild(this.useZipCheckbox);
      this.convertButton = this.DynamsoftButton("Convert");
      this.convertButton.style.marginLeft = "10px";
      this.convertButton.addEventListener("click",function(){
        pThis.convertAndDownload();
      })
      this.convertActions.appendChild(formatSelector);
      this.convertActions.appendChild(useZip);
      this.convertActions.appendChild(this.convertButton);
      actionsContainer.appendChild(this.convertActions);
      this.container.appendChild(this.files);
      this.container.appendChild(actionsContainer);
    }
    

    The styles are defined in a CSS module named style.module.css (here, we use CSS module to avoid class names conflicts):

    .oneFile {
      background: #F5F5F5;
      display: flex;
      padding: 1em;
      align-items: center;
      justify-content: space-between;
    }
    
    .title {
      width: 30%;
      word-break: break-all;
    }
    
    .convertActions {
      padding-right: 1em;
      text-align: right;
    }
    
    .actions {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-top: 10px;
    }
    
    .textUpperCase {
      text-transform: uppercase;
    }
    
    .primaryBtn {
      display: inline-block;
      padding: 5px 10px;
      background-color: #fe8e14;
      color: #fff;
      text-align: center;
      border: 2px solid #fe8e14;
      cursor: pointer;
      transition: ease-in 0.2s all;
      font-family: "sans-serif";
    }
    
    @media(any-hover:hover){
      .primaryBtn:hover {
        box-shadow: -4px 4px 0 0 #000;
        transform: translate(4px,-4px);
      }
      .secondaryBtn:hover {
        color: #fea543;
      }
    }
    
    .primaryBtn:active {
      color: #fea543;
    }
    
  3. Append selected files in a files array. If the file is a zip file, then add the image files in it.

    private filesSelected:File[] = [];
    async appendFiles(){
      if (this.fileInput.files) {
        for (let index = 0; index < this.fileInput.files.length; index++) {
          let file = this.fileInput.files[index];
          if (file.name.endsWith(".zip")) {
            this.useZipCheckbox.checked = true;
            await this.loadImagesFromZip(file);
          }else{
            this.filesSelected.push(file);
          }
             
        }
      }
    }
       
    async loadImagesFromZip(zipFile:File){
      const buffer = await zipFile.arrayBuffer();
      let zip = new JSZip();
      await zip.loadAsync(buffer);
      const files = zip.files;
      const filenames = Object.keys(files);
      for (let index = 0; index < filenames.length; index++) {
        const filename = filenames[index];
        const lowerCase = filename.toLowerCase();
        const file = files[filename];
        if (file.dir === false) {
          if (lowerCase.endsWith(".jpg") || lowerCase.endsWith(".jpeg") || lowerCase.endsWith(".png") || lowerCase.endsWith(".bmp") || lowerCase.endsWith(".pdf") || lowerCase.endsWith(".tif") || lowerCase.endsWith(".tiff")) {
            let blob:Blob = await file.async("blob");
            let imgFile = new File([blob],filename);
            this.filesSelected.push(imgFile);
          }
        }
      }
    }
    
  4. List selected files. A file item displays the name and the size of the file and a button to delete the file. If the name is too long, use ellipses to make it shorter.

    listFiles(){
      this.files.innerHTML = "";
      for (let index = 0; index < this.filesSelected.length; index++) {
        const file = this.filesSelected[index];
        let oneFile = this.fileItem(file);
        if (index != this.filesSelected.length - 1) {
          oneFile.style.marginBottom = "10px";
        }
        this.files.appendChild(oneFile);
      }
      if (this.filesSelected.length > 0) {
        this.convertActions.style.display = "";
      }else{
        this.convertActions.style.display = "none";
      }
    }
    
    fileItem(file:File){
      let container = document.createElement("div");
      container.className = styles.oneFile;
      let title = document.createElement("div");
      title.innerText = this.useEllipsesForLongText(file.name);
      title.className = styles.title;
      let fileSize = document.createElement("div");
      fileSize.innerText = file.size/1000 + "kb";
      let deleteButton = this.DynamsoftButton("Delete");
      let pThis = this;
      deleteButton.addEventListener("click",function(){
        pThis.deleteSelected(file);
      })
      container.appendChild(title);
      container.appendChild(fileSize);
      container.appendChild(deleteButton);
      return container;
    }
    
    deleteSelected(file:File){
      let index = this.filesSelected.indexOf(file);
      this.filesSelected.splice(index,1);
      this.listFiles();
    }
    
    useEllipsesForLongText(text:string){
      if (text.length>28){
        text = text.substring(0,14) + "..." + text.substring(text.length-14,text.length);
      }
      return text;
    }
    
  5. If the convert button is clicked, convert the files and download them. If the Download as zip checkbox is checked, then download the files in a zip.

    async convertAndDownload(){
      this.convertButton.innerText = "Converting...";
      let zip:JSZip|undefined;
      if (this.useZipCheckbox.checked) {
        zip = new JSZip();
      }
      for (let index = 0; index < this.filesSelected.length; index++) {
        const file = this.filesSelected[index];
        this.DWObject.RemoveAllImages();
        await this.loadImageFromFile(file);
        await this.save(file,zip);
      }
      if (this.useZipCheckbox.checked && zip) {
        let pThis = this;
        zip.generateAsync({type:"blob"}).then(function(content) {
          pThis.downloadBlob(content,"images.zip");
        });
      }
      this.convertButton.innerText = "Convert";
    }
       
    async save(file:File,zip:JSZip|undefined){
      if (this.useZipCheckbox.checked === false) {
        await this.saveImages(file);
      }else{
        if (zip) {
          await this.appendImagesToZip(file,zip);
        }
      }
    }
       
    async saveImages(file:File){
      let selectedFormatIndex = this.formatSelect.selectedIndex;
      //"JPG","PNG","PDF","TIFF"
      let fileType = 7;
      let extension = "";
      if (selectedFormatIndex === 0) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_JPG;
        extension = ".jpg";
      }else if (selectedFormatIndex === 1) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG;
        extension = ".png";
      }else if (selectedFormatIndex === 2) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF;
        extension = ".pdf";
      }else if (selectedFormatIndex === 3) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_TIF;
        extension = ".tiff";
      }
      if (selectedFormatIndex > 1) {
        let blob = await this.getBlob(this.getImageIndices(),fileType);
        this.downloadBlob(blob,this.getFileNameWithoutExtension(file.name)+extension);
      }else{
        if (this.DWObject.HowManyImagesInBuffer > 1) {
          for (let index = 0; index < this.DWObject.HowManyImagesInBuffer; index++) {
            let blob = await this.getBlob([index],fileType);
            this.downloadBlob(blob,this.getFileNameWithoutExtension(file.name)+"-"+index+extension);
          }
        }else{
          let blob = await this.getBlob([0],fileType);
          this.downloadBlob(blob,this.getFileNameWithoutExtension(file.name)+extension);
        }
      }
    }
    
    async appendImagesToZip(file:File,zip:JSZip){
      let selectedFormatIndex = this.formatSelect.selectedIndex;
      //"JPG","PNG","PDF","TIFF"
      let fileType = 7;
      let extension = "";
      if (selectedFormatIndex === 0) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_JPG;
        extension = ".jpg";
      }else if (selectedFormatIndex === 1) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PNG;
        extension = ".png";
      }else if (selectedFormatIndex === 2) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF;
        extension = ".pdf";
      }else if (selectedFormatIndex === 3) {
        fileType = Dynamsoft.DWT.EnumDWT_ImageType.IT_TIF;
        extension = ".tiff";
      }
      if (selectedFormatIndex > 1) {
        let blob = await this.getBlob(this.getImageIndices(),fileType);
        zip.file(this.getFileNameWithoutExtension(file.name)+extension, blob);
      }else{
        if (this.DWObject.HowManyImagesInBuffer > 1) {
          for (let index = 0; index < this.DWObject.HowManyImagesInBuffer; index++) {
            let blob = await this.getBlob([index],fileType);
            zip.file(this.getFileNameWithoutExtension(file.name)+"-"+index+extension, blob);
          }
        }else{
          let blob = await this.getBlob([0],fileType);
          zip.file(this.getFileNameWithoutExtension(file.name)+extension, blob);
        }
      }
    }
       
    downloadBlob(content:Blob,filename:string){
      const link = document.createElement('a')
      link.href = URL.createObjectURL(content);
      link.download = filename;
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
    }
    

All right, we’ve now finished writing the demo.

Step 4: Package the Converter as a Reusable Library

We can package the image converter as a library according to this guide.

  1. Install devDependencies:

    npm install -D @types/node vite-plugin-dts
    
  2. Create a new vite.config.ts file:

    // vite.config.ts
    import { resolve } from 'path';
    import { defineConfig } from 'vite';
    import dts from 'vite-plugin-dts';
    // https://vitejs.dev/guide/build.html#library-mode
    export default defineConfig({
      build: {
        lib: {
          entry: resolve(__dirname, 'src/index.ts'),
          name: 'image-converter',
          fileName: 'image-converter',
        },
      },
      plugins: [dts()],
    });
    
  3. Add the entry points of our package to package.json.

    {
      "name": "image-converter",
      "version": "0.0.0",
      "private": false,
      "type": "module",
      "main": "./dist/image-converter.umd.cjs",
      "module": "./dist/image-converter.js",
      "types": "./dist/index.d.ts",
      "exports": {
        ".": {
          "import": "./dist/image-converter.js",
          "require": "./dist/image-converter.cjs"
        }
      },
      "files": [
        "dist/*.css",
        "dist/*.js",
        "dist/*.cjs",
        "dist/*.d.ts"
      ],
    }
    

Run npm run build. Then, we can have the packaged files in the dist:

dist
    image-converter.d.ts
    image-converter.js
    image-converter.umd.cjs
    index.d.ts
    main.d.ts
    style.css

Common Issues and Edge Cases

  • Large PDF files cause slow conversion: Multi-page PDFs with high-resolution images consume significant memory in the browser. Break very large files into smaller batches or reduce the resolution before conversion.
  • ZIP extraction fails for unsupported file types: The loadImagesFromZip function only processes known image/PDF extensions. If users include non-image files in a ZIP, those entries are silently skipped — consider adding a status message listing skipped files.
  • CORS errors loading the Dynamic Web TWAIN resources: If you self-host the SDK resources instead of using the unpkg CDN, ensure your server sends the correct Access-Control-Allow-Origin headers for the .wasm and .js resource files.

Source Code

Get the source code of the library to have a try:

https://github.com/tony-xlh/Image-Converter

Other Demos