How to Build an Image Cropper Web Component

Image cropping is a common image processing task where we only need a potion of an image.

In most cases, we make a rectangular selection box for cropping.

Image Cropper

But for cases like document scanning, we need to make a polygonal selection box. Perspective transformation is needed to normalize the document.

Normalizing

In this article, we are going to use Stencil.js to create an image cropper web component. We can use it to make a rectangular or polygonal selection box and get the cropped image.

Dynamsoft Document Normalizer is used to detect document borders and normalize images.

Online demo

New Project

Run the following command to create a new Stencil Component project:

npm init stencil

Generate a New Component

Generate a new component named image-cropper.

npx stencil g image-cropper

To test the component, we can modify the src\index.html file:

-   <my-component first="Stencil" last="'Don't call me a framework' JS"></my-component>
+   <image-cropper>
+     <p>Inner elements</p>
+   </image-cropper>

Then run the following command to test it:

npm run start

Add an SVG Element

In the JSX, add an SVG element. We are going to use it to display the image and allow users to adjust the selection.

svgElement:SVGElement;
render() {
  return (
    <Host>
      <svg 
        version="1.1" 
        ref={(el) => this.svgElement = el as SVGElement}
        class="cropper-svg"
        xmlns="http://www.w3.org/2000/svg"
      >
      </svg>
      <slot></slot>
    </Host>
  );
}

Display a Local Image in the SVG Element

  1. Add an img prop to the component.

    @Prop() img?: HTMLImageElement;
    
  2. Add an image element to the SVG element.

    <svg 
      version="1.1" 
      ref={(el) => this.svgElement = el as SVGElement}
      class="cropper-svg"
      xmlns="http://www.w3.org/2000/svg"
    >
       <image href={this.img ? this.img.src : ""}></image>
    </svg>
    
  3. Watch the img prop and update the viewBox state used by the SVG element.

    @State() viewBox:string = "0 0 1280 720";
    @Watch('img')
    watchImgPropHandler(newValue: HTMLImageElement) {
      if (newValue) {
        this.viewBox = "0 0 "+newValue.naturalWidth+" "+newValue.naturalHeight;
      }
    }
    
    render() {
      return (
        <Host>
          <svg 
            version="1.1" 
            ref={(el) => this.svgElement = el as SVGElement}
            class="cropper-svg"
            xmlns="http://www.w3.org/2000/svg"
          >
            <image href={this.img ? this.img.src : ""}></image>
          </svg>
          <slot></slot>
        </Host>
      );
    }
    
  4. In index.html, load an image file to a new image element, pass it to the cropper and display the cropper.

    <!DOCTYPE html>
    <html dir="ltr" lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
        <title>Stencil Component Starter</title>
        <script type="module" src="/build/image-cropper-component.esm.js"></script>
        <script nomodule src="/build/image-cropper-component.js"></script>
        <style>
          img {
            max-width: 100%;
            max-height: 350px;
          }
    
          #cropper {
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
          }
        </style>
      </head>
      <body>
        <div>
          Load local image:
          <input type="file" id="file" onchange="loadImageFromFile();" accept=".jpg,.jpeg,.png,.bmp" />
          <div>
            <button onclick="openCropper()">Open Cropper</button>
          </div>
        </div>
        <div>
          <div>Original:</div>
          <div class="orginalImageContainer"></div>
          <div>Cropped:</div>
          <div class="croppedImageContainer">
            <img id="cropped" />
          </div>
        </div>
        <div id="cropper" style="display:none;">
          <image-cropper></image-cropper>
        </div>
      </body>
      <script>
        function loadImageFromFile() { 
          let files = document.getElementById('file').files;
          if (files.length == 0) {
            return;
          }
          let file = files[0];
          fileReader = new FileReader();
          fileReader.onload = function(e){
            let container = document.getElementsByClassName("orginalImageContainer")[0];
            container.innerHTML = "";
            let img = document.createElement("img");
            img.src = e.target.result;
            img.id = "original";
            container.appendChild(img);
          };
          fileReader.onerror = function () {
            console.warn('oops, something went wrong.');
          };
          fileReader.readAsDataURL(file);
        }
    
        async function openCropper(){
          if (!document.getElementById("original")) {
            return;
          }
          let cropper = document.querySelector("image-cropper");
          cropper.img = document.getElementById("original");
          document.getElementById("cropper").style.display = "";
        }
      </script>
    </html>
    

Display a Selection Box

Next, we need to use SVG to draw a selection box like the following:

It is a polygon with eight handlers on its edges. Its SVG code is the following:

<svg version="1.1" class="cropper-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="320">
  <defs>
    <mask id="myMask">
      <rect x="0" y="0" width="512" height="512" fill="white"></rect>
      <rect x="72" y="46" width="14" height="14" fill="black"></rect>
      <rect x="172" y="46" width="14" height="14" fill="black"></rect>
      <rect x="272" y="46" width="14" height="14" fill="black"></rect>
      <rect x="272" y="146" width="14" height="14" fill="black"></rect>
      <rect x="272" y="246" width="14" height="14" fill="black"></rect>
      <rect x="172" y="246" width="14" height="14" fill="black"></rect><rect x="72" y="246" width="14" height="14" fill="black"></rect>
      <rect x="72" y="146" width="14" height="14" fill="black"></rect>
    </mask>
  </defs>
  <polygon mask="url(#myMask)" points="79,53 279,53 279,253 79,253" stroke="orange" stroke-width="2" fill="transparent"></polygon>
  <rect x="72" y="46" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="172" y="46" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="272" y="46" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="272" y="146" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="272" y="246" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="172" y="246" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="72" y="246" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent"></rect>
  <rect x="72" y="146" width="14" height="14" stroke="orange" stroke-width="2" fill="transparent">
  </rect>
</svg>

Here, we use SVG’s mask feature to mask out the polygon under the rectangular handlers.

Let’s implement this in the component’s class.

  1. Define several geometry interfaces: Quad, Rect and Point.

    export interface Quad{
      points:[Point,Point,Point,Point];
    }
    
    export interface Point{
      x:number;
      y:number;
    }
    
    export interface Rect{
      x:number;
      y:number;
      width:number;
      height:number;
    }
    
  2. Add a state named points:

    @State() points:[Point,Point,Point,Point] = undefined;
    
  3. Add a rect prop and a quad prop so that the users can pass initial values for the selection box.

    @Prop() rect?: Rect;
    @Prop() quad?: Quad;
    usingQuad = false;
    
    @Watch('rect')
    watchRectPropHandler(newValue: Rect) {
      if (newValue) {
        this.usingQuad = false;
        const point1:Point = {x:newValue.x,y:newValue.y};
        const point2:Point = {x:newValue.x+newValue.width,y:newValue.y};
        const point3:Point = {x:newValue.x+newValue.width,y:newValue.y+newValue.height};
        const point4:Point = {x:newValue.x,y:newValue.y+newValue.height};
        this.points = [point1,point2,point3,point4];
      }
    }
    
    @Watch('quad')
    watchQuadPropHandler(newValue: Quad) {
      if (newValue) {
        this.usingQuad = true;
        this.points = newValue.points;
      }
    }
    
  4. Update the SVG element to draw the polygon and rectangles.

    <svg 
      version="1.1" 
      ref={(el) => this.svgElement = el as SVGElement}
      class="cropper-svg"
      xmlns="http://www.w3.org/2000/svg"
      viewBox={this.viewBox}
    >
      {this.renderHandlersMaskDefs()}
      <image href={this.img ? this.img.src : ""}></image>
      <polygon
        mask="url(#myMask)"
        points={this.getPointsData()}
        class="cropper-controls"
        stroke-width="2"
        fill="transparent"
      >
      </polygon>
      {this.renderHandlers()}
    </svg>
    

    The renderHandlers function:

    handlers:number[] = [0,1,2,3,4,5,6,7];
    @State() selectedHandlerIndex:number = -1;
    renderHandlers(){
      if (!this.points) {
        return (<div></div>)
      }
      return (
        <Fragment>
          {this.handlers.map(index => (
            <rect 
              x={this.getHandlerPos(index,"x")} 
              y={this.getHandlerPos(index,"y")} 
              width={this.getHandlerSize()}
              height={this.getHandlerSize()} 
              class="cropper-controls"
              stroke-width={index === this.selectedHandlerIndex ? 4 * this.getRatio() : 2 * this.getRatio()}
              fill="transparent"
              onMouseDown={(e:MouseEvent)=>this.onHandlerMouseDown(e,index)}
              onMouseUp={(e:MouseEvent)=>this.onHandlerMouseUp(e)}
              onTouchStart={(e:TouchEvent)=>this.onHandlerTouchStart(e,index)}
            />
          ))}
        </Fragment>
      )
    }
    

    The renderHandlersMaskDefs function:

    renderHandlersMaskDefs(){
      if (!this.points) {
        return (<div></div>)
      }
      return (
        <defs>
          <mask id="myMask">
            <rect 
              x="0" 
              y="0" 
              width={this.img ? this.img.naturalWidth : "0"}
              height={this.img ? this.img.naturalHeight : "0"}
              fill="white" />
            {this.handlers.map(index => (
              <rect 
                x={this.getHandlerPos(index,"x")} 
                y={this.getHandlerPos(index,"y")} 
                width={this.getHandlerSize()}
                height={this.getHandlerSize()} fill="black" 
              />
            ))}
          </mask>
        </defs>
      )
    }
    

    Below are some helper functions. Please note that the 8 handlers’ positions are calculated based on the four points.

    getHandlerPos(index:number,key:string) {
      let pos = 0;
      let size = this.getHandlerSize();
      if (index === 0){
        pos = this.points[0][key];
      }else if (index === 1) {
        pos = this.points[0][key] + (this.points[1][key] - this.points[0][key])/2;
      }else if (index === 2) {
        pos = this.points[1][key];
      }else if (index === 3) {
        pos = this.points[1][key] + (this.points[2][key] - this.points[1][key])/2;
      }else if (index === 4) {
        pos = this.points[2][key];
      }else if (index === 5) {
        pos = this.points[3][key] + (this.points[2][key] - this.points[3][key])/2;
      }else if (index === 6) {
        pos = this.points[3][key];
      }else if (index === 7) {
        pos = this.points[0][key] + (this.points[3][key] - this.points[0][key])/2;
      }
      pos = pos - size/2;
      return pos;
    }
    
    getHandlerSize() {
      let ratio = this.getRatio();
      let size:number = 20;
      if (this.handlersize) {
        try {
          size = parseInt(this.handlersize);
        } catch (error) {
          console.log(error);
        }
      }
      return Math.ceil(size*ratio);
    }
    
  5. In index.html, pass a rectangle for providing the initial coordinates.

    let cropper = document.querySelector("image-cropper");
    cropper.rect = {x:50,y:50,width:200,height:200};
    

Add Mouse Events to Adjust the Selection

There are two ways to adjust the selection. Dragging the polygon to move the entire selection or dragging the handlers to adjust the points.

Drag the Polygon to Move the Entire Selection

  1. Add MouseDown and MouseUp events for the polygon.

    <polygon
      onMouseDown={(e:MouseEvent)=>this.onPolygonMouseDown(e)}
      onMouseUp={(e:MouseEvent)=>this.onPolygonMouseUp(e)}
    >
    </polygon>
    
  2. In the mouse down event, record the clicked point and the points for the polygon as originalPoints when clicked.

    originalPoints:[Point,Point,Point,Point] = undefined;
    polygonMouseDown:boolean = false;
    polygonMouseDownPoint:Point = {x:0,y:0};
    onPolygonMouseDown(e:MouseEvent){
      e.stopPropagation();
      this.originalPoints = JSON.parse(JSON.stringify(this.points));
      this.polygonMouseDown = true;
      let coord = this.getMousePosition(e,this.svgElement);
      this.polygonMouseDownPoint.x = coord.x;
      this.polygonMouseDownPoint.y = coord.y;
    }
    

    The mouse’s coordinates are converted to the coordinates in SVG with the following function:

    //Convert the screen coordinates to the SVG's coordinates from https://www.petercollingridge.co.uk/tutorials/svg/interactive/dragging/
    getMousePosition(event:any,svg:any) {
      let CTM = svg.getScreenCTM();
      if (event.targetTouches) { //if it is a touch event
        let x = event.targetTouches[0].clientX;
        let y = event.targetTouches[0].clientY;
        return {
          x: (x - CTM.e) / CTM.a,
          y: (y - CTM.f) / CTM.d
        };
      }else{
        return {
          x: (event.clientX - CTM.e) / CTM.a,
          y: (event.clientY - CTM.f) / CTM.d
        };
      }
    }
    
  3. In the mouse up event, set the mouse down status to false and reset the selected handler’s index.

    onPolygonMouseUp(e:MouseEvent){
      e.stopPropagation();
      this.selectedHandlerIndex = -1;
      this.polygonMouseDown = false;
    }
    
  4. Add MouseMove and MouseUp events for the SVG element.

    <svg 
      onMouseUp={()=>this.onSVGMouseUp()}
      onMouseMove={(e:MouseEvent)=>this.onSVGMouseMove(e)}
    >
    </svg>
    
  5. In the mouse up event for the SVG, set the mouse down status to false and reset the selected handler’s index as in step 2.

    onSVGMouseUp(){
      this.selectedHandlerIndex = -1;
      this.polygonMouseDown = false;
    }
    
  6. In the mouse move event for the SVG, get the current coordinates of the mouse and calculate the points after moving to move the polygon.

    onSVGMouseMove(e:MouseEvent){
      if (this.polygonMouseDown) {
        let coord = this.getMousePosition(e,this.svgElement);
        let offsetX = coord.x - this.polygonMouseDownPoint.x;
        let offsetY = coord.y - this.polygonMouseDownPoint.y;
        let newPoints = JSON.parse(JSON.stringify(this.originalPoints));
        for (const point of newPoints) {
          point.x = point.x + offsetX;
          point.y = point.y + offsetY;
          if (point.x < 0 || point.y < 0 || point.x > this.img.naturalWidth || point.y > this.img.naturalHeight){
            console.log("reach bounds");
            return;
          }
        }
        this.points = newPoints;
      }
    }
    

Drag a Selected Handler to Adjust the Selection

  1. Add MouseDown and MouseUp events for the rectangular handlers.

    {this.handlers.map(index => (
      <rect 
        onMouseDown={(e:MouseEvent)=>this.onHandlerMouseDown(e,index)}
        onMouseUp={(e:MouseEvent)=>this.onHandlerMouseUp(e)}
      />
    ))}
    
  2. In the mouse down event, record the clicked point and the points for the polygon as originalPoints when clicked. Record the clicked handler’s index, as well.

    onHandlerMouseDown(e:MouseEvent,index:number){
      e.stopPropagation();
      let coord = this.getMousePosition(e,this.svgElement);
      this.originalPoints = JSON.parse(JSON.stringify(this.points));
      this.handlerMouseDownPoint.x = coord.x;
      this.handlerMouseDownPoint.y = coord.y;
      this.selectedHandlerIndex = index;
    }
    
  3. In the mouse up event, set the selected handler’s index to -1.

    onHandlerMouseUp(e:MouseEvent){
      e.stopPropagation();
      this.selectedHandlerIndex = -1;
    }
    
  4. In the mouse move event for the SVG, update the points if a handler is selected. If the user passes a rectangle for cropping instead of a quadrilateral, then keep the selection rectangular.

    onSVGMouseMove(e:MouseEvent){
      if (this.polygonMouseDown) {
        let coord = this.getMousePosition(e,this.svgElement);
        let offsetX = coord.x - this.polygonMouseDownPoint.x;
        let offsetY = coord.y - this.polygonMouseDownPoint.y;
        let newPoints = JSON.parse(JSON.stringify(this.originalPoints));
        for (const point of newPoints) {
          point.x = point.x + offsetX;
          point.y = point.y + offsetY;
          if (point.x < 0 || point.y < 0 || point.x > this.img.naturalWidth || point.y > this.img.naturalHeight){
            console.log("reach bounds");
            return;
          }
        }
        this.points = newPoints;
      }
      if (this.selectedHandlerIndex >= 0) {
        let pointIndex = this.getPointIndexFromHandlerIndex(this.selectedHandlerIndex);
        let coord = this.getMousePosition(e,this.svgElement);
        let offsetX = coord.x - this.handlerMouseDownPoint.x;
        let offsetY = coord.y - this.handlerMouseDownPoint.y;
        let newPoints = JSON.parse(JSON.stringify(this.originalPoints));
        if (pointIndex != -1) {
          let selectedPoint = newPoints[pointIndex];
          selectedPoint.x = this.originalPoints[pointIndex].x + offsetX;
          selectedPoint.y = this.originalPoints[pointIndex].y + offsetY;
          if (this.usingQuad === false) { //rect mode
            if (pointIndex === 0) {
              newPoints[1].y = selectedPoint.y;
              newPoints[3].x = selectedPoint.x;
            }else if (pointIndex === 1) {
              newPoints[0].y = selectedPoint.y;
              newPoints[2].x = selectedPoint.x;
            }else if (pointIndex === 2) {
              newPoints[1].x = selectedPoint.x;
              newPoints[3].y = selectedPoint.y;
            }else if (pointIndex === 3) {
              newPoints[0].x = selectedPoint.x;
              newPoints[2].y = selectedPoint.y;
            }
          }
        }else{ //mid-point handlers
          if (this.selectedHandlerIndex === 1) {
            newPoints[0].y = this.originalPoints[0].y + offsetY;
            newPoints[1].y = this.originalPoints[1].y + offsetY;
          }else if (this.selectedHandlerIndex === 3) {
            newPoints[1].x = this.originalPoints[1].x + offsetX;
            newPoints[2].x = this.originalPoints[2].x + offsetX;
          }else if (this.selectedHandlerIndex === 5) {
            newPoints[2].y = this.originalPoints[2].y + offsetY;
            newPoints[3].y = this.originalPoints[3].y + offsetY;
          }else if (this.selectedHandlerIndex === 7) {
            newPoints[0].x = this.originalPoints[0].x + offsetX;
            newPoints[3].x = this.originalPoints[3].x + offsetX;
          }
        }
        this.points = newPoints;
      }
    }
    

Add Touch Events to Adjust the Selection

For devices with a touch screen, we also need to add touch events. Basically, we need to do the following things:

  1. Do not trigger mouse events if a touch event is triggered.
  2. TouchMove and MouseMove can share the same logic.
  3. TouchStart is equivalent to MouseDown and TouchEnd is equivalent to MouseUp. For touch devices, we can keep the touched handler as selected even if the touch ends and drag the SVG element to adjust it as it may be difficult to see the content under a handler when our finger is above it.

Create an optional footer for the users to cancel the operation or confirm the operation:

@Prop() hidefooter?: string;
@Event() confirmed?: EventEmitter<void>;
@Event() canceled?: EventEmitter<void>;
onCanceled(){
  if (this.canceled){
    console.log("emit");
    this.canceled.emit();
  }
}

onConfirmed(){
  if (this.confirmed){
    this.confirmed.emit();
  }
}

renderFooter(){
  if (this.hidefooter === "") {
    return "";
  }
  return (
    <div class="footer">
      <section class="items">
        <div class="item accept-cancel" onClick={() => this.onCanceled()}>
          <img src="data:image/svg+xml,%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 512 512' enable-background='new 0 0 512 512' xml:space='preserve'%3E%3Ccircle fill='%23727A87' cx='256' cy='256' r='256'/%3E%3Cg id='Icon_5_'%3E%3Cg%3E%3Cpath fill='%23FFFFFF' d='M394.2,142L370,117.8c-1.6-1.6-4.1-1.6-5.7,0L258.8,223.4c-1.6,1.6-4.1,1.6-5.7,0L147.6,117.8 c-1.6-1.6-4.1-1.6-5.7,0L117.8,142c-1.6,1.6-1.6,4.1,0,5.7l105.5,105.5c1.6,1.6,1.6,4.1,0,5.7L117.8,364.4c-1.6,1.6-1.6,4.1,0,5.7 l24.1,24.1c1.6,1.6,4.1,1.6,5.7,0l105.5-105.5c1.6-1.6,4.1-1.6,5.7,0l105.5,105.5c1.6,1.6,4.1,1.6,5.7,0l24.1-24.1 c1.6-1.6,1.6-4.1,0-5.7L288.6,258.8c-1.6-1.6-1.6-4.1,0-5.7l105.5-105.5C395.7,146.1,395.7,143.5,394.2,142z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E" />
        </div>
        <div class="item accept-use" onClick={() => this.onConfirmed()}>
          <img src="data:image/svg+xml,%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 512 512' enable-background='new 0 0 512 512' xml:space='preserve'%3E%3Ccircle fill='%232CD865' cx='256' cy='256' r='256'/%3E%3Cg id='Icon_1_'%3E%3Cg%3E%3Cg%3E%3Cpath fill='%23FFFFFF' d='M208,301.4l-55.4-55.5c-1.5-1.5-4-1.6-5.6-0.1l-23.4,22.3c-1.6,1.6-1.7,4.1-0.1,5.7l81.6,81.4 c3.1,3.1,8.2,3.1,11.3,0l171.8-171.7c1.6-1.6,1.6-4.2-0.1-5.7l-23.4-22.3c-1.6-1.5-4.1-1.5-5.6,0.1L213.7,301.4 C212.1,303,209.6,303,208,301.4z'/%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/svg%3E" />
        </div>
      </section>
    </div>
  )
}

Get the Cropping Results

Add several methods to get the adjusted coordinates or the cropped image.

  • Getting the cropped image:

     @Prop() license?: string;
     @Method()
     async getCroppedImage(perspectiveTransform?:boolean,colorMode?:"binary"|"gray"|"color"):Promise<string>
     {
       if (perspectiveTransform && window["Dynamsoft"]) {
         if (!this.cvr) {
           await this.initCVR();
         }
         if (colorMode) {
           let templateName = "NormalizeDocument_Color";
           if (colorMode === "binary") {
             templateName = "NormalizeDocument_Binary";
           } else if (colorMode === "gray") {
             templateName = "NormalizeDocument_Gray";
           } else {
             templateName = "NormalizeDocument_Color";
           }
         }
         let quad = await this.getQuad();
         let settings = await this.cvr.getSimplifiedSettings(templateName);
         settings.roi  = quad;
         settings.roiMeasuredInPercentage = false;
         await this.cvr.updateSettings(templateName, settings);
         let normalizedImagesResult:CapturedResult = await this.cvr.capture(img,templateName);
         let normalizedImageResultItem:NormalizedImageResultItem = (normalizedImagesResult.items[0] as NormalizedImageResultItem);
         return normalizedImageResultItem.image.toCanvas().toDataURL();
       }else{
         let ctx = this.canvasElement.getContext("2d");
         let rect = await this.getRect();
         this.canvasElement.width = rect.width;
         this.canvasElement.height = rect.height;
         ctx.drawImage(this.img, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
         return this.canvasElement.toDataURL();
       }
     }
    
     async initCVR(){
       window["Dynamsoft"]["License"]["LicenseManager"].initLicense(this.license);
       window["Dynamsoft"]["CVR"]["CaptureVisionRouter"].preloadModule(["DDN"]);
       this.cvr = await window["Dynamsoft"]["CVR"]["CaptureVisionRouter"].createInstance();
       this.cvr.initSettings("{\"CaptureVisionTemplates\": [{\"Name\": \"Default\"},{\"Name\": \"DetectDocumentBoundaries_Default\",\"ImageROIProcessingNameArray\": [\"roi-detect-document-boundaries\"]},{\"Name\": \"DetectAndNormalizeDocument_Default\",\"ImageROIProcessingNameArray\": [\"roi-detect-and-normalize-document\"]},{\"Name\": \"NormalizeDocument_Binary\",\"ImageROIProcessingNameArray\": [\"roi-normalize-document-binary\"]},  {\"Name\": \"NormalizeDocument_Gray\",\"ImageROIProcessingNameArray\": [\"roi-normalize-document-gray\"]},  {\"Name\": \"NormalizeDocument_Color\",\"ImageROIProcessingNameArray\": [\"roi-normalize-document-color\"]}],\"TargetROIDefOptions\": [{\"Name\": \"roi-detect-document-boundaries\",\"TaskSettingNameArray\": [\"task-detect-document-boundaries\"]},{\"Name\": \"roi-detect-and-normalize-document\",\"TaskSettingNameArray\": [\"task-detect-and-normalize-document\"]},{\"Name\": \"roi-normalize-document-binary\",\"TaskSettingNameArray\": [\"task-normalize-document-binary\"]},  {\"Name\": \"roi-normalize-document-gray\",\"TaskSettingNameArray\": [\"task-normalize-document-gray\"]},  {\"Name\": \"roi-normalize-document-color\",\"TaskSettingNameArray\": [\"task-normalize-document-color\"]}],\"DocumentNormalizerTaskSettingOptions\": [{\"Name\": \"task-detect-and-normalize-document\",\"SectionImageParameterArray\": [{\"Section\": \"ST_REGION_PREDETECTION\",\"ImageParameterName\": \"ip-detect-and-normalize\"},{\"Section\": \"ST_DOCUMENT_DETECTION\",\"ImageParameterName\": \"ip-detect-and-normalize\"},{\"Section\": \"ST_DOCUMENT_NORMALIZATION\",\"ImageParameterName\": \"ip-detect-and-normalize\"}]},{\"Name\": \"task-detect-document-boundaries\",\"TerminateSetting\": {\"Section\": \"ST_DOCUMENT_DETECTION\"},\"SectionImageParameterArray\": [{\"Section\": \"ST_REGION_PREDETECTION\",\"ImageParameterName\": \"ip-detect\"},{\"Section\": \"ST_DOCUMENT_DETECTION\",\"ImageParameterName\": \"ip-detect\"},{\"Section\": \"ST_DOCUMENT_NORMALIZATION\",\"ImageParameterName\": \"ip-detect\"}]},{\"Name\": \"task-normalize-document-binary\",\"StartSection\": \"ST_DOCUMENT_NORMALIZATION\",   \"ColourMode\": \"ICM_BINARY\",\"SectionImageParameterArray\": [{\"Section\": \"ST_REGION_PREDETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_DETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_NORMALIZATION\",\"ImageParameterName\": \"ip-normalize\"}]},  {\"Name\": \"task-normalize-document-gray\",   \"ColourMode\": \"ICM_GRAYSCALE\",\"StartSection\": \"ST_DOCUMENT_NORMALIZATION\",\"SectionImageParameterArray\": [{\"Section\": \"ST_REGION_PREDETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_DETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_NORMALIZATION\",\"ImageParameterName\": \"ip-normalize\"}]},  {\"Name\": \"task-normalize-document-color\",   \"ColourMode\": \"ICM_COLOUR\",\"StartSection\": \"ST_DOCUMENT_NORMALIZATION\",\"SectionImageParameterArray\": [{\"Section\": \"ST_REGION_PREDETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_DETECTION\",\"ImageParameterName\": \"ip-normalize\"},{\"Section\": \"ST_DOCUMENT_NORMALIZATION\",\"ImageParameterName\": \"ip-normalize\"}]}],\"ImageParameterOptions\": [{\"Name\": \"ip-detect-and-normalize\",\"BinarizationModes\": [{\"Mode\": \"BM_LOCAL_BLOCK\",\"BlockSizeX\": 0,\"BlockSizeY\": 0,\"EnableFillBinaryVacancy\": 0}],\"TextDetectionMode\": {\"Mode\": \"TTDM_WORD\",\"Direction\": \"HORIZONTAL\",\"Sensitivity\": 7}},{\"Name\": \"ip-detect\",\"BinarizationModes\": [{\"Mode\": \"BM_LOCAL_BLOCK\",\"BlockSizeX\": 0,\"BlockSizeY\": 0,\"EnableFillBinaryVacancy\": 0,\"ThresholdCompensation\" : 7}],\"TextDetectionMode\": {\"Mode\": \"TTDM_WORD\",\"Direction\": \"HORIZONTAL\",\"Sensitivity\": 7},\"ScaleDownThreshold\" : 512},{\"Name\": \"ip-normalize\",\"BinarizationModes\": [{\"Mode\": \"BM_LOCAL_BLOCK\",\"BlockSizeX\": 0,\"BlockSizeY\": 0,\"EnableFillBinaryVacancy\": 0}],\"TextDetectionMode\": {\"Mode\": \"TTDM_WORD\",\"Direction\": \"HORIZONTAL\",\"Sensitivity\": 7}}]}");
       this.cvr.maxCvsSideLength = 99999;
     }
    

    If perspective transformation is enabled, use Dynamsoft Document Normalizer to get the normalized image, otherwise, use Canvas to crop the image. When using Dynamsoft Document Normalizer, we can also set the color mode for the result: black & white, gray or color. To use Dynamsoft Document Normalizer, we need to include it via CDN and set a license for it.

    Include the library in HTML:

     <script src="https://cdn.jsdelivr.net/npm/dynamsoft-core@3.0.10/dist/core.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/dynamsoft-document-normalizer@2.0.11/dist/ddn.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/dynamsoft-capture-vision-router@2.0.11/dist/cvr.js"></script>
    

    Set the license:

     <image-cropper license="your license"></image-cropper>
    

    You can apply for a license here.

  • Getting the coordinates:

     @Method()
     async getPoints():Promise<[Point,Point,Point,Point]>
     {
       return this.points;
     }
    
     @Method()
     async getQuad():Promise<Quad>
     {
       return {points:this.points};
     }
    
     @Method()
     async getRect():Promise<Rect>
     {
       let minX:number;
       let minY:number;
       let maxX:number;
       let maxY:number;
       for (const point of this.points) {
         if (!minX) {
           minX = point.x;
           maxX = point.x;
           minY = point.y;
           maxY = point.y;
         }else{
           minX = Math.min(point.x,minX);
           minY = Math.min(point.y,minY);
           maxX = Math.max(point.x,maxX);
           maxY = Math.max(point.y,maxY);  
         }
       }
       minX = Math.floor(minX);
       maxX = Math.floor(maxX);
       minY = Math.floor(minY);
       maxY = Math.floor(maxY);
       return {x:minX,y:minY,width:maxX - minX,height:maxY - minY};
     }
    

Add a Detect Method

Dynamsoft Document Normalizer can detect the borders of documents. We can also add a detect method for the component so that we can automatically get a suitable selection box.

@Method()
async detect(source: string | HTMLImageElement | Blob | HTMLCanvasElement):Promise<DetectedQuadResult[]>
{
  if (window["Dynamsoft"]) {
    if (!this.cvr) {
      await this.initCVR();
    }
    let result:CapturedResult = await this.cvr.capture(source,"DetectDocumentBoundaries_Default");
    let results:DetectedQuadResultItem[] = [];
    for (let index = 0; index < result.items.length; index++) {
      const item = (result.items[index] as DetectedQuadResultItem);
      results.push(item);
    }
    return results;
  }else{
    throw "Dynamsoft Document Normalizer not found";
  }
}

Source Code

All right, we’ve now finished the web component. Get the source code of the image cropper web component to have a try:

https://github.com/tony-xlh/image-cropper-component