便利ページ：javascriptで画像をOCRする

「[便利ページ：Javascriptでちょっとした便利な機能を作ってみた](https://qiita.com/poruruba/items/6c67c7eef34ed302feb3 )」のシリーズものです。

今回は、Javascriptで画像をクリップボードからペーストして、任意の画像の個所からテキストを抽出します。すなわちOCRです。
また、必要に応じて、ペーストした画像を他のPCとも共有できるようにします。共有するには、サーバの立ち上げが必要です。OCRの機能だけでよければ、サーバの立ち上げは不要です。

以下のGitHubに上げたユーティリティページを更新しています。

poruruba/utilities

https://github.com/poruruba/utilities

すぐに動かしたい場合は、以下のページにおいて、ユーティリティ→OCRを選択してください。

https://poruruba.github.io/utilities/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/90b8b995-f904-17a8-0b22-7bf73c949b1a.png)

左上のテキストエリアのところに、クリップボード経由で画像を張り付けると、その下にその画像が表示されます。画像ファイルをドラッグ＆ドロップのでもOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/66b767ef-d62b-ffa8-9d2e-cb13d8e88c9e.png)

あとは、その画像上で、OCRしたいところをマウスクリックで囲うと、その部分をOCRして右上のテキストエリアに結果を表示します。
日本語か英語かどうか、セレクトできるようにしていますので、選んだ方が精度は高くなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8449bfff-56db-83fa-6e53-4d9e6adccbba.png)

OCRには以下を使わせていただきました。

Tesseract.js

https://tesseract.projectnaptha.com/

(2022/1/3 更新）
Windowsにおいて、Webページ上の画像をコピー＆ペーストすると、特殊なフォーマットのようで、それに対応させました。GitHubの方をご参照ください。
https://docs.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format

(2022/2/24 更新)
共有機能は、別のPWAアプリに分離しました。
　[クリップボードを共有するPWAアプリの作成](https://qiita.com/poruruba/items/c6fc602ed284be178c13)


# 実装

Javascriptソースコードです。Vueのコンポーネントで実装しているため、HTML部とJavascript部の両方があります。

```js:/js/comp/comp_ocrshare.js
const MAX_UPLOAD_IMAGE_SIZE = 1024;

var startX;
var startY;
var endX;
var endY;
var drawingCanvas;
var imageCanvas;
var drawingCtx;
var drawed = false;

export default {
  mixins: [mixins_bootstrap],
  template: `
<div>
  <h2 class="modal-header">OCR・共有</h2>

  <div class="row float-end">
    <div class="col-auto">
      <input type="text" class="form-control" v-model="base_url" placeholder="base_url">
    </div>
    <div class="col-auto">
      <label class="title">secret</label>
    </div>
    <div class="col-auto">
      <input type="password" class="form-control" v-model="share_secret">
    </div>
  </div>
  <br>
  <br>
  <div class="row">
    <div class="col-3">
      <textarea placeholder="ここに画像かテキストをペースト(Ctrl-V)してください。" class="form-control" style="text-align: center; resize: none;" rows="5" 
        v-on:paste="do_paste" v-on:drop.prevent="do_file_pase" v-on:dragover.prevent readonly>
      </textarea>
    </div>
    <div class="col-9">
      <button class="btn btn-secondary btn-sm float-end" v-on:click="trim_space">スペース除去</button>
      <button class="btn btn-secondary btn-sm float-end" v-on:click="trim_cr">改行除去</button>
      <textarea placeholder="ここにペーストした文字列が表示されます。" class="form-control" v-model="result_text" rows="3"></textarea><br>
    </div>
  </div>
  <div class="row">
    <button class="btn btn-secondary btn-sm col-auto" v-on:click="all_region">全選択</button>
    <button class="btn btn-secondary btn-sm col-auto" v-on:click="clear_region">範囲クリア</button>
    <button v-bind:disabled="!share_secret" class="btn btn-secondary btn-sm col-auto" v-on:click="do_share">共有</button>
    <button v-bind:disabled="!share_secret" class="btn btn-secondary btn-sm col-auto" v-on:click="get_share">取得</button>
    <span class="col-auto">
        <select class="form-select" v-model="ocr_lang">
            <option value="jpn">jpn</option>
            <option value="eng">eng</option>
        </select>
    </span>
  </div>
  <div class="row">
    <div style="position: relative">
      <canvas id="image_canvas" style="position: absolute; left: 0; top: 0; z-index: 0; border: 1px solid;"></canvas>
      <canvas id="region_canvas" style="position: absolute; left: 0; top: 0; z-index: 1; border: 1px solid;" v-on:drop.prevent="do_file_pase" v-on:dragover.prevent
        v-on:mousemove="onMouseMove" v-on:mouseup="onMouseUp" v-on:mouseover="onMouseOut" v-on:mousedown="onMouseDown"></canvas>
    </div>
  </div>
</div>`,
  data: function () {
    return {
      result_text: "",
      share_secret: "",
      canvasWidth: 0,
      canvasHeight: 0,
      base_url: "",
      ocr_lang: "jpn",  
    }
  },
  methods: {
    /* OCR・共有 */
    do_scan: function(){
      this.progress_open();
      Tesseract.recognize(
          drawingCanvas,
          this.ocr_lang
      )
      .then( result =>{
          console.log(result);
          this.result_text = result.data.text;
          drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          drawingCtx.strokeRect(startX, startY, endX - startX, endY - startY);
      })
      .catch(error =>{
          console.error(error);
      })
      .finally(() =>{
          this.progress_close();
      });
    },
    do_file_pase: function(e){
        console.log(e);
        if( e.dataTransfer.files.length == 0 )
            return;

        var file = e.dataTransfer.files[0];
        console.log(file.type);

        if(file.type.startsWith('image/')){
            var reader = new FileReader();
            reader.onload = (e) => {
                var data_url = e.target.result;
                this.set_dataurl(data_url);
            };
            reader.readAsDataURL(file);
        }else
        if( file.type.startsWith('text/')){
            var reader = new FileReader();
            reader.onload = (e) => {
                this.result_text = e.target.result;
            };
            reader.readAsText(file);
        }else{
            alert('サポートしていません。');
        }
    },
    do_paste: async function(e){
        console.log(e);
        if (e.clipboardData.types.length == 0)
            return;

        var item = e.clipboardData.items[0];
        console.log(item.type);
        if( item.type.startsWith('text/')){
            e.clipboardData.items[0].getAsString(str =>{
                console.log(str);
                this.result_text = str;
            });
            return;
        }else
        if( item.type.startsWith('image/')){
            var imageFile = e.clipboardData.items[0].getAsFile();
            var reader = new FileReader();
            reader.onload = (e) => {
                var data_url = e.target.result;
                this.set_dataurl(data_url);
            };
            reader.readAsDataURL(imageFile);
        }else{
            alert('サポートしていません。');
        }
    },
    onMouseMove: function(e){
  //            console.log('onMouseMove');
        if( !this.mousePressed )
            return;
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
        endX = e.layerX;
        endY = e.layerY;
        drawingCtx.strokeRect(startX, startY, endX - startX, endY - startY);
    },
    onMouseUp: function(e){
  //            console.log('onMouseUp');
        if( !this.mousePressed )
            return;
        this.mousePressed = false;
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
        endX = e.layerX;
        endY = e.layerY;
        if( startX == endX || startY == endY )
            return;
        drawingCtx.strokeRect(startX, startY, endX - startX, endY - startY);
        this.region_selected();
    },
    onMouseDown: function(e){
  //            console.log('onMouseDown');
        if( !drawed )
            return;
        this.mousePressed = true;
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
        startX = e.layerX;
        startY = e.layerY;
    },
    onMouseOut: function(ev){
  //            console.log('onMouseOut');
        if( !this.mousePressed )
            return;
        this.mousePressed = false;
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    },
    all_region: function(){
        if( !drawed )
            return;
        startX = 0;
        startY = 0;
        endX = this.canvasWidth - 1;
        endY = this.canvasHeight - 1;
        this.region_selected();
    },
    clear_region: function(){
        startX = startY = endX = endY = 0;
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    },
    region_selected: function(){
        console.log(startX, startY, endX, endY, this.canvasWidth, this.canvasHeight);

        var width  = Math.abs(startX - endX);
        var height = Math.abs(startY - endY);
        drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
        drawingCtx.drawImage(imageCanvas, Math.min(startX, endX), Math.min(startY, endY),
                               width, height, Math.min(startX, endX), Math.min(startY, endY), width, height);
        this.do_scan();
    },
    do_share: async function(){
        if( !this.share_secret ){
            alert('secretを入力してください。');
            return;
        }
        try{
            this.progress_open();
            var element = document.createElement('canvas');
            var scale = this.auto_scale(this.canvasWidth, this.canvasHeight, MAX_UPLOAD_IMAGE_SIZE);
            element.width = Math.floor(this.canvasWidth / scale);
            element.height = Math.floor(this.canvasHeight / scale);
            var ctx = element.getContext('2d');
            ctx.width = Math.floor(this.canvasWidth / scale);
            ctx.height = Math.floor(this.canvasHeight / scale);
            ctx.drawImage(imageCanvas, 0, 0, this.canvasWidth, this.canvasHeight, 0, 0, element.width, element.height);
            var data_url = element.toDataURL('image/jpeg');
            var params = {
                secret: this.share_secret,
                data_url: data_url,
                result_text: this.result_text,
            };
            console.log("data_url=" + data_url.length);
            await do_post(this.base_url + "/share-set", params);
            Cookies.set('share_base_url', this.base_url, { expires: 365 });
            Cookies.set('share_secret', this.share_secret, { expires: 365 });
            alert('共有しました。');
        }catch(error){
            alert(error);
        }finally{
            this.progress_close();
        }
    },
    get_share: async function(){
        if( !this.share_secret ){
            alert('secretを入力してください。');
            return;
        }
        try{
            this.progress_open();
            var params = {
                secret: this.share_secret,
            };
            var json = await do_post(this.base_url + "/share-get", params);
            if( json.status != 'ok' ){
                alert(json.message);
                return;
            }
            this.set_dataurl(json.result.data_url);
            this.result_text = json.result.result_text;
            Cookies.set('share_base_url', this.base_url, { expires: 365 });
            Cookies.set('share_secret', this.share_secret, { expires: 365 });
        }catch(error){
            alert(error);
        }finally{
            this.progress_close();
        }
    },
    set_dataurl: function(data_url){
        var image = new Image();
        image.onload = () =>{
            this.canvasWidth = image.width;
            this.canvasHeight = image.height;
            imageCanvas.width = this.canvasWidth;
            imageCanvas.height = this.canvasHeight;
            var ctx = imageCanvas.getContext('2d');
            ctx.drawImage(image, 0, 0, this.canvasWidth, this.canvasHeight);
            drawingCanvas.width = this.canvasWidth;
            drawingCanvas.height = this.canvasHeight;
            drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
            drawed = true;
        };
        image.src = data_url;
    },
    trim_space: function(){
        this.result_text = this.result_text.replace(/ /g, "");
    },
    trim_cr: function(){
        this.result_text = this.result_text.replace(/\n/g, "");
    },
    auto_scale: function(width, height, limit){
        var scale = 1;
        while(Math.floor(width / scale) > limit || Math.floor(height / scale) > limit){
            scale++;
        }
        return scale;
    },
  },
  mounted: function(){
    this.share_secret = Cookies.get('share_secret');
    this.base_url = Cookies.get('share_base_url');

    imageCanvas = document.querySelector("#image_canvas");
    drawingCanvas = document.querySelector("#region_canvas");
    drawingCtx = drawingCanvas.getContext("2d");
    drawingCtx.lineWidth = 3;
  }
};
```

その前に、index.htmlで必要なJavascriptをロードしておきます。

```html:/index.html
    <script src='https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js'></script>
```

大事なのは以下の部分です。

```js:/js/comp/comp_ocrshare.js
    do_scan: function(){
      this.progress_open();
      Tesseract.recognize(
          drawingCanvas,
          this.ocr_lang
      )
      .then( result =>{
          console.log(result);
          this.result_text = result.data.text;
・・・・
```

それ以外の実装は以下の通りです。

do_file_pase
　画像ファイルやテキストファイルのドロップを受け付けるところです

do_paste
　クリップボードから画像データやテキストデータを受け付けるところです。

set_dataurl
　受け付けた画像情報をDataURL形式にして、HTMLのCanvasに描画しています。

onMouseMove
onMouseUp
onMouseDown
onMouseOut
　マウスを追跡して、OCR対象の四角い枠を表示するためのものです。

all_region
　マウスを使わずに、画像の全領域をOCR対象にする場合の処理です。

region_selected
　マウスで範囲指定した領域の画像を抽出して、OCR処理を実行しています。

do_scan
　ここにTesseract.jsを使ったOCR処理があります。

trim_space
trim_cr
　OCR結果には、不要なスペースや改行が入っている場合があり、それを取り除く処理です。

do_share
get_share
　今回詳しくは説明しませんが、他のPCと共有するために、いったんサーバに画像ファイルをアップロードしたり、取り込んだりするための処理です。
　DataURL(Base64)形式で送信していますが、大きすぎるとサーバ側に負担がかかるので、サイズをMAX_UPLOAD_IMAGE_SIZE以内に制限しています。

以上