How to Build a Capacitor Plugin for Document Scanning

Capacitor is an open-source native runtime created by the Ionic team for building Web Native apps. We can use it to create cross-platform iOS, Android, and Progressive Web Apps with JavaScript, HTML, and CSS.

Using Capacitor plugins, we can use JavaScript to interface directly with native APIs. In this article, we are going to build a document scanning Capacitor plugin using Dynamsoft Document Normalizer SDK. Dynamsoft Document Normalizer can detect the borders of documents and run perspective transformation to get a corrected document image.

Build a Document Scanning Capacitor Plugin

Let’s do this in steps.

New Plugin Project

In a new terminal, run the following:

npm init @capacitor/plugin

We will be prompted to input relevant project info.

√ What should be the npm package of your plugin?
 ... capacitor-plugin-dynamsoft-document-normalizer
√ What directory should be used for your plugin?
 ... capacitor-plugin-dynamsoft-document-normalizer
√ What should be the Package ID for your plugin?

    Package IDs are unique identifiers used in apps and plugins. For plugins,
    they're used as a Java namespace. They must be in reverse domain name
    notation, generally representing a domain name that you or your company owns.

 ... com.dynamsoft.capacitor.ddn
√ What should be the class name for your plugin?
 ... DocumentNormalizer
√ What is the repository URL for your plugin?
 ... https://github.com/tony-xlh/capacitor-plugin-dynamsoft-document-normalizer

Create an Example Project

In order to test the plugin, we can create an example project.

Under the root of the plugin, create an example folder and start a webpack project.

git clone https://github.com/wbkd/webpack-starter
mv webpack-starter example # rename webpack-starter to example

Install the Capacitor plugin to the example project:

cd example
npm install ..

Then, we can run npm start to test the example project.

Next, we are going to implement the Web, Android and iOS parts of the plugin.

Web Implementation

Add Dynamsoft Document Normalizer as a Dependency

npm install dynamsoft-document-normalizer@1.0.10

Write Definitions

Define interfaces in src/definitions.ts. The DocumentNormalizerPlugin provides methods to initialize Dynamsoft Document Normalizer, update its settings and use it to detect and normalize documents.

import { DCEFrame } from "dynamsoft-camera-enhancer";
import { DetectedQuadResult } from "dynamsoft-document-normalizer";
import { Quadrilateral } from "dynamsoft-document-normalizer/dist/types/interface/quadrilateral";

export interface DocumentNormalizerPlugin {
  initialize(): Promise<void>;
  initLicense(options: {license: string}): Promise<void>;
  initRuntimeSettingsFromString(options: {template:string}): Promise<void>;
  detect(options:{source:string | DCEFrame}): Promise<{results:DetectedQuadResult[]}>;
  normalize(options:{source:string | DCEFrame, quad:Quadrilateral}): Promise<{result:NormalizedImageResult}>;
  /**
  * Web Only
  */
  setEngineResourcesPath(options:{path:string}): Promise<void>; 
}

export interface NormalizedImageResult {
  data: string;
}

Implement the Interfaces

  1. Initialization.

    The initialize method will create an instance of Dynamsoft Document Normalizer for use. We can set the license and engine resources path before creating an instance.

    export class DocumentNormalizerWeb extends WebPlugin implements DocumentNormalizerPlugin {
      private normalizer:DocumentNormalizer | undefined;
      private engineResourcesPath: string = "https://cdn.jsdelivr.net/npm/dynamsoft-document-normalizer@1.0.10/dist/";
      private license: string = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==";
      async initialize(): Promise<void> {
        try {
          DocumentNormalizer.license = this.license;
          DocumentNormalizer.engineResourcePath = this.engineResourcesPath;
          this.normalizer = await DocumentNormalizer.createInstance();
        } catch (error) {
          throw error;
        }
      }
       
       
    async initLicense(options: { license: string }): Promise<void> {
      this.license = options.license;
    }
    
    async setEngineResourcesPath(options: { path: string; }): Promise<void> {
      this.engineResourcesPath = options.path;
    }}
    
  2. Updating the runtime settings.

    We can update the runtime settings of Document Normalizer using a JSON template to adjust its behaviors like which document color mode to use.

    async initRuntimeSettingsFromString(options: { template: string }): Promise<void> {
      if (this.normalizer) {
        await this.normalizer.setRuntimeSettings(options.template);
      } else {
        throw new Error("DDN not initialized.");
      }
    }
    
  3. Detecting documents.

    We can pass the image as a base64-encoded string or a DCEFrame from the JS version of Dynamsoft Camera Enhancer for document detection.

    async detect(options: { source: string | DCEFrame }): Promise<{results:DetectedQuadResult[]}> {
      if (this.normalizer) {
        let detectedQuads = await this.normalizer.detectQuad(options.source);
        return {results:detectedQuads};
      } else {
        throw new Error("DDN not initialized.");
      }
    }
    
  4. Normalizing a document using a detected quadrilateral result.

    We can pass the image as a base64-encoded string or a DCEFrame from the JS version of Dynamsoft Camera Enhancer along with the quadrilateral result from document detection to normalize the document image.

    async normalize(options: { source: string | DCEFrame, quad:Quadrilateral}): Promise<{result:NormalizedImageResult}> {
      if (this.normalizer) {
        let result = await this.normalizer.normalize(options.source,{quad:options.quad});
        let normalizedResult:NormalizedImageResult = {
          data:result.image.toCanvas().toDataURL()
        }
        return {result:normalizedResult};
      } else {
        throw new Error("DDN not initialized.");
      }
    }
    

Android Implementation

Add Dynamsoft Document Normalizer as a Dependency

Open android/build.gradle to add the Dynamsoft Document Normalizer dependency:

rootProject.allprojects {
    repositories {
        maven {
            url "https://download2.dynamsoft.com/maven/aar"
        }
    }
}


dependencies {
    // DDN
    implementation 'com.dynamsoft:dynamsoftdocumentnormalizer:1.0.10'
}

Implementation

Open DocumentNormalizerPlugin.java to implement the plugin.

  1. Initialization.

    private DocumentNormalizer ddn;
    @PluginMethod
    public void initialize(PluginCall call) {
        try {
            if (ddn == null) {
                ddn = new DocumentNormalizer();
            }
            call.resolve();
        } catch (DocumentNormalizerException e) {
            e.printStackTrace();
            call.reject(e.getMessage());
        }
    }
    
  2. License initialization.

    @PluginMethod
    public void initLicense(PluginCall call) {
        String license = call.getString("license","DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==");
        LicenseManager.initLicense(license, getContext(), new LicenseVerificationListener() {
            @Override
            public void licenseVerificationCallback(boolean isSuccess, CoreException error) {
                if(!isSuccess){
                    error.printStackTrace();
                    call.reject(error.getMessage());
                }else{
                    call.resolve();
                }
            }
        });
    }
    
  3. Updating the runtime settings.

    @PluginMethod
    public void initRuntimeSettingsFromString(PluginCall call) {
        String template = call.getString("template");
        if (ddn != null) {
            try {
                ddn.initRuntimeSettingsFromString(template);
                call.resolve();
            } catch (DocumentNormalizerException e) {
                e.printStackTrace();
                call.reject(e.getMessage());
            }
        }else{
            call.reject("DDN not initialized");
        }
    }
    
  4. Detecting the document.

    @PluginMethod
    public void detect(PluginCall call) {
        String source = call.getString("source");
        source = source.replaceFirst("data:.*?;base64,","");
        if (ddn != null) {
            try {
                JSObject response = new JSObject();
                JSArray detectionResults = new JSArray();
                DetectedQuadResult[] results = ddn.detectQuad(Utils.base642Bitmap(source));
                if (results != null) {
                    for (DetectedQuadResult result:results) {
                        detectionResults.put(Utils.getMapFromDetectedQuadResult(result));
                    }
                }
                response.put("results",detectionResults);
                call.resolve(response);
            } catch (DocumentNormalizerException e) {
                e.printStackTrace();
                call.reject(e.getMessage());
            }
        }else{
            call.reject("DDN not initialized");
        }
    }
    
  5. Normalizing the document image.

    @PluginMethod
    public void normalize(PluginCall call) {
        JSObject quad = call.getObject("quad");
        String source = call.getString("source");
        source = source.replaceFirst("data:.*?;base64,","");
        if (ddn != null) {
            try {
                Point[] points = Utils.convertPoints(quad.getJSONArray("points"));
                Quadrilateral quadrilateral = new Quadrilateral();
                quadrilateral.points = points;
                NormalizedImageResult result = ddn.normalize(Utils.base642Bitmap(source),quadrilateral);
                Bitmap bm = result.image.toBitmap();
                JSObject response = new JSObject();
                JSObject resultObject = new JSObject();
                resultObject.put("data",Utils.bitmap2Base64(bm));
                response.put("result",resultObject);
                call.resolve(response);
            }catch (Exception e) {
                call.reject(e.getMessage());
            }
        }else{
            call.reject("DDN not initialized");
        }
    }
    
  6. A Utils class is created for wrapping the results and base64-bitmap conversion.

    public class Utils {
        public static Bitmap base642Bitmap(String base64) {
            byte[] decode = Base64.decode(base64,Base64.DEFAULT);
            return BitmapFactory.decodeByteArray(decode,0,decode.length);
        }
    
        public static String bitmap2Base64(Bitmap bitmap) {
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
            return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT);
        }
    
        public static Point[] convertPoints(JSONArray pointsArray) throws JSONException {
            Point[] points = new Point[4];
            for (int i = 0; i < pointsArray.length(); i++) {
                JSONObject pointMap = pointsArray.getJSONObject(i);
                Point point = new Point();
                point.x = pointMap.getInt("x");
                point.y = pointMap.getInt("y");
                points[i] = point;
            }
            return points;
        }
    
        public static JSObject getMapFromDetectedQuadResult(DetectedQuadResult result){
            JSObject map = new JSObject ();
            map.put("confidenceAsDocumentBoundary",result.confidenceAsDocumentBoundary);
            map.put("location",getMapFromLocation(result.location));
            return map;
        }
    
        private static JSObject getMapFromLocation(Quadrilateral location){
            JSObject map = new JSObject();
            JSArray  points = new JSArray();
            for (Point point: location.points) {
                JSObject pointAsMap = new JSObject();
                pointAsMap.put("x",point.x);
                pointAsMap.put("y",point.y);
                points.put(pointAsMap);
            }
            map.put("points",points);
            return map;
        }
    }
    

iOS Implementation

Add Dynamsoft Document Normalizer as a Dependency

Open CapacitorPluginDynamsoftDocumentNormalizer.podspec to add the Dynamsoft Document Normalizer dependency:

s.dependency 'DynamsoftDocumentNormalizer', '= 1.0.10'

Write Definitions

Open DocumentNormalizerPlugin.m to define the methods.

// Define the plugin using the CAP_PLUGIN Macro, and
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
CAP_PLUGIN(DocumentNormalizerPlugin, "DocumentNormalizer",
           CAP_PLUGIN_METHOD(initialize, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(initLicense, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(initRuntimeSettingsFromString, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(detect, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(normalize, CAPPluginReturnPromise);
)

Implementation

Open DocumentNormalizerPlugin.swift to implement the plugin.

  1. Initialization.

    private var ddn:DynamsoftDocumentNormalizer!;
    @objc func initialize(_ call: CAPPluginCall) {
        if ddn == nil {
            ddn = DynamsoftDocumentNormalizer()
        }
        call.resolve()
    }
    
  2. License initialization.

    @objc func initLicense(_ call: CAPPluginCall) {
        let license = call.getString("license") ?? "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="
        DynamsoftLicenseManager.initLicense(license, verificationDelegate: self)
        call.resolve()
    }
    
  3. Updating the runtime settings.

     @objc func initRuntimeSettingsFromString(_ call: CAPPluginCall) {
        let template = call.getString("template") ?? ""
        if ddn != nil {
            if template != "" {
                do {
                    try ddn.initRuntimeSettingsFromString(template)
                    call.resolve()
                }catch {
                    print("Unexpected error: \(error).")
                    call.reject(error.localizedDescription)
                }
            }else{
                call.reject("Empty template")
            }
        }else{
            call.reject("DDN not initialized")
        }
    }
    
  4. Detecting the document.

    @objc func detect(_ call: CAPPluginCall) {
        var base64 = call.getString("source") ?? ""
        base64 = Utils.removeDataURLHead(base64)
        let image = Utils.convertBase64ToImage(base64)
        var returned_results: [Any] = []
        let results = try? ddn.detectQuadFromImage(image!)
        if results != nil {
            for result in results! {
                returned_results.append(Utils.wrapDetectionResult(result:result))
            }
        }
        call.resolve(["results":returned_results])
    }
    
  5. Normalizing the document image.

     @objc func normalize(_ call: CAPPluginCall) {
        do {
            var base64 = call.getString("source") ?? ""
            base64 = Utils.removeDataURLHead(base64)
            let image = Utils.convertBase64ToImage(base64)
            let quad = call.getObject("quad")
            let points = quad!["points"] as! [[String:NSNumber]]
            let quadrilateral = iQuadrilateral.init()
            quadrilateral.points = Utils.convertPoints(points)
               
            let normalizedImageResult = try ddn.normalizeImage(image!, quad: quadrilateral)
            let normalizedUIImage = try? normalizedImageResult.image.toUIImage()
            let normalziedResultAsBase64 = Utils.getBase64FromImage(normalizedUIImage!)
            call.resolve(["result":["data":normalziedResultAsBase64]])
        }catch {
            print("Unexpected error: \(error).")
            call.reject(error.localizedDescription)
        }
    }
    
  6. A Utils class is created for wrapping the results and base64-UIImage conversion.

    class Utils {
        static public func convertBase64ToImage(_ imageStr:String) ->UIImage?{
            if let data: NSData = NSData(base64Encoded: imageStr, options:NSData.Base64DecodingOptions.ignoreUnknownCharacters)
            {
                if let image: UIImage = UIImage(data: data as Data)
                {
                    return image
                }
            }
            return nil
        }
           
        static func getBase64FromImage(_ image:UIImage) -> String{
            let dataTmp = image.jpegData(compressionQuality: 100)
            if let data = dataTmp {
                return data.base64EncodedString()
            }
            return ""
        }
           
        static func removeDataURLHead(_ str: String) -> String {
            var finalStr = str
            do {
                let pattern = "data:.*?;base64,"
                let regex = try NSRegularExpression(pattern: pattern, options: NSRegularExpression.Options.caseInsensitive)
                finalStr = regex.stringByReplacingMatches(in: str, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSMakeRange(0, str.count), withTemplate: "")
            }
            catch {
                print(error)
            }
            return finalStr
        }
           
        static func convertPoints(_ points:[[String:NSNumber]]) -> [CGPoint] {
            var CGPoints:[CGPoint] = [];
            for point in points {
                let x = point["x"]!
                let y = point["y"]!
                let intX = x.intValue
                let intY = y.intValue
                let cgPoint = CGPoint(x: intX, y: intY)
                CGPoints.append(cgPoint)
            }
            return CGPoints
        }
           
        static func wrapDetectionResult (result:iDetectedQuadResult) -> [String: Any] {
            var dict: [String: Any] = [:]
            dict["confidenceAsDocumentBoundary"] = result.confidenceAsDocumentBoundary
            dict["location"] = wrapLocation(location:result.location)
            return dict
        }
               
        static private func wrapLocation (location:iQuadrilateral?) -> [String: Any] {
            var dict: [String: Any] = [:]
            var points: [[String:CGFloat]] = []
            let CGPoints = location!.points as! [CGPoint]
            for point in CGPoints {
                var pointDict: [String:CGFloat] = [:]
                pointDict["x"] = point.x
                pointDict["y"] = point.y
                points.append(pointDict)
            }
            dict["points"] = points
            return dict
        }
    }
    

Capacitor Document Scanner Online Demo

All right, we’ve now finished writing the Capacitor plugin. A document scanner demo app has been created in vanilla JS using it. You can try it out using this online demo.

Ionic Demo

Check out this article if you are using the Ionic framework.

Source Code

You can find the code of the plugin and the example in the following link:

https://github.com/tony-xlh/capacitor-plugin-dynamsoft-document-normalizer/